Per head's parallel-prep brief while m/mBrian#73 (migration script + [schema] node) is being built mBrian-side. NO mBrian-MCP-backed implementation yet — the migration worker may refine the landed node/edge shape and building the impl now risks rework. Built ONLY the parts stable regardless of mBrian internals: 1. CONSUMER INVENTORY (docs/plans/slice-b-adapter-contract.md §1) - Every *store.Store read method (15 methods) with signature + semantics - Every call site across web/, internal/aggregate/, mcp/ — table form - Item / ItemLink field-by-field shape contract: which fields come direct from node columns, which from edge-walk, which from metadata-unpack - Direct pgxpool access flagged out-of-scope (admin counts, bulk tx, links event-date update — slice C reworks those) - Views (5j) explicitly NOT in scope per m's Q5=(a) 2. INTERFACE CONTRACT (store/adapter.go) - ItemReader Go interface — 15 methods, pure projax-shaped structs in/out, zero mBrian type leakage - var _ ItemReader = (*Store)(nil) compile-time assertion proving the existing pgx-backed *Store satisfies the contract today 3. SKELETON (store/adapter.go MBrianReader) - Empty struct (mBrian client choice deferred to slice B impl) - All 15 methods stubbed, return errNotImplementedSliceB - var _ ItemReader = (*MBrianReader)(nil) keeps the stubs in lockstep with the interface as slice B grows - Each stub carries a one-line comment naming the §3 gap(s) it resolves at impl time - `go build ./...` green; `go vet ./store/` green 4. GAP FLAGS (docs/plans/slice-b-adapter-contract.md §3) - item_links.rel free-form annotation → mBrian edge.note (add to m/mBrian#73 §1 for the migration script) - ItemLink.RefID per-rel-type extraction rule (caldav URL vs gitea owner/repo vs mai project uuid) - paths[] recomputation cost (per-request memoisation) - AllTags aggregation (full-scan ok at m's scale; tag-graph deferred per m's Q8) - Roots / MaiOrphans "no outbound child_of edge" predicate - ItemsCreatedInRange scoped to projax_origin marker - Item.Source / SourceRefID constant + mai-edge-derived fields - ItemLinkWithItem join shape (two queries + in-memory join vs bulk MCP helper) - Admin counts — recommend adding Counts(ctx) to ItemReader for cohesion Stays parked after this. Slice B IMPL (mBrian-MCP client wiring + per- method bodies + handler rename from s.Store.X to s.Items.X) waits on the migration completing and uuid map landing.
16 KiB
Phase 6 Slice B — read-path adapter contract
Status: prep work (this doc). No implementation.
Branch: mai/kahn/phase-6-sliceB-prep.
Author: kahn (coder, prep mode), 2026-05-29.
Parent plan: docs/plans/mbrian-backend-migration.md (on main).
Scope boundary: contract + compile-checking skeleton only. The mBrian-backed implementation waits on m/mBrian#73 landing the migration + handing over the uuid map.
§1 — Consumer inventory
Every read-path call site against *store.Store and the projax-shaped Item / ItemLink types. The interface (§2) is the union of these.
§1.1 — *store.Store read methods (source: store/store.go)
| method | signature | semantics |
|---|---|---|
ListAll |
(ctx) ([]*Item, error) |
every live item, ordered by paths NULLS FIRST, slug |
GetByID |
(ctx, id) (*Item, error) |
single item by uuid |
GetByPath |
(ctx, path) (*Item, error) |
resolve dev.paliad style path to leaf item |
GetByPathOrSlug |
(ctx, key) (*Item, error) |
path first, fall back to bare slug |
Roots |
(ctx) ([]*Item, error) |
items with cardinality(parent_ids) = 0 |
MaiOrphans |
(ctx) ([]*Item, error) |
mai-managed root items needing classify |
ListByFilters |
(ctx, SearchFilters) ([]*Item, error) |
structured search (status / mgmt / has-link / paths-prefix) |
Search |
(ctx, q, limit) ([]*Item, error) |
trigram + FTS title/content/aliases |
AllTags |
(ctx) ([]string, error) |
union of every item's tags |
LinksByType |
(ctx, itemID, refType) ([]*ItemLink, error) |
one item's links of a given ref_type (empty = all) |
LinksByRefType |
(ctx, refType) ([]*ItemLink, error) |
every link of a given ref_type across items |
DatedLinks |
(ctx, itemID) ([]*ItemLink, error) |
one item's links anchored to a date (PER artifacts) |
DatedLinksRange |
(ctx, from, to) ([]*ItemLinkWithItem, error) |
dated links within window, joined with their item |
RecentDocuments |
(ctx, since, limit) ([]*ItemLinkWithItem, error) |
recent dated docs, joined with their item |
ItemsCreatedInRange |
(ctx, from, to) ([]*Item, error) |
items created within window |
§1.2 — Consumer call sites (by file)
Each row = one read-path call site. Direct Pool access (admin.go counts, bulk.go filter-tx, links.go event-date update) is flagged separately at the bottom — those rework targets are out of slice B's read-path scope.
| consumer | method | use case |
|---|---|---|
web/server.go handleTree |
ListAll, AllTags, linkKindsByItem (LinksByRefType ×N) |
render /views/tree with chip-counted forest |
web/server.go handleDetail |
GetByPath ×2 (PER fallback), LinksByType (caldav), DatedLinks |
render /i/{path} detail page |
web/server.go parentOptions |
ListAll |
populate parent on /new + /reparent |
web/server.go handleClassify |
MaiOrphans, parentOptions |
render /admin/classify |
web/dashboard.go handleDashboard |
ListAll, LinksByRefType (caldav), LinksByType (gitea) ×N, RecentDocuments |
Tiles + tasks + events + docs cards |
web/calendar.go handleCalendar |
ListAll |
month grid scope |
web/timeline.go handleTimeline + buildTimeline |
ListAll, linkKindsByItem |
chronological spine |
web/graph.go handleGraph |
ListAll, AllTags |
DAG SVG render |
web/bulk.go handleBulk |
ListAll, AllTags, GetByID |
/admin/bulk filtered checklist |
web/caldav.go (admin + create/unlink) |
ListAll, LinksByRefType, LinksByType, GetByPath |
/admin/caldav surface |
web/gitea.go detailIssues |
LinksByType (gitea-repo) |
/i/{path} issues card |
web/gitea_writeback.go |
GetByPath, LinksByType |
issue close/comment/create handlers |
web/links.go (add/remove/list) |
GetByPath, DatedLinks |
/i/{path} documents section |
web/dashboard_pin.go |
SetPinned — WRITE, not slice B |
pin toggle (slice C) |
web/views.go handleViewRender |
ListAll, AllTags, linkKindsByItem |
/views/{slug} render (5j) |
web/system_views.go legacyRedirect |
GetViewByID — views CRUD (NOT in scope) |
legacy 5i uuid → 5j slug redirect |
internal/aggregate aggregator.go |
takes LinkLister interface (LinksByType + ItemsCreatedInRange) |
shared fan-out across tasks/events/issues/docs |
mcp/tools.go (read tools) |
ListByFilters, LinksByRefType, GetByID, GetByPathOrSlug, LinksByType, ListAll, Search, RecentDocuments (via dashboard fan-out reuse) |
every read-side MCP tool |
§1.3 — Direct Pool access (out-of-scope for slice B, flagged for slice C)
These bypass the store API and pull *pgxpool.Pool directly. Slice C (write-path) reworks them; flagging here so slice B's interface stays minimal:
web/admin.go— three count queries (SELECT count(*) FROM projax.items WHERE …) for the admin index. Either: (a) addCounts(ctx) (AdminCounts, error)to the adapter, (b) compute in-handler fromListAll. Adapter pick.web/bulk.go handleBulkApply— multi-row UPDATE inside a tx. Pure write; slice C.web/links.go handleSetEventDate— single UPDATE onitem_links.event_date. Pure write; slice C.
§1.4 — *Item + *ItemLink shape contract (consumer side)
Adapter MUST return these exact field sets in the result types. Nothing under metadata.projax.* in mBrian leaks to consumers; the adapter parses + materialises into the Item fields below.
| field | semantics in slice B adapter |
|---|---|
Item.ID |
mBrian node uuid (post-migration); preserved old uuid OK per Q11 |
Item.Kind |
[]string{"project", ...} — mBrian node.type[] 1:1 |
Item.Title, Item.Slug, Item.ContentMD, Item.Aliases |
mBrian node.title/slug/content_md/aliases 1:1 |
Item.Paths |
derived from child_of edge walk + the node's own slug. Adapter computes per-call (cached per-request) |
Item.ParentIDs |
derived from outbound child_of edges |
Item.Metadata |
node.metadata MINUS the projax sub-key (which gets unpacked into the struct fields below) |
Item.Status |
node.metadata.projax.status (default "active") |
Item.Pinned, Item.Archived |
node.pinned, node.archived 1:1 |
Item.StartTime, Item.EndTime |
node.metadata.projax.start_time / .end_time (timestamptz strings) |
Item.Tags, Item.Management, Item.TimelineExclude |
node.metadata.projax.tags / .management / .timeline_exclude |
Item.Public, Item.PublicDescription, Item.PublicLiveURL, Item.PublicSourceURL, Item.PublicScreenshots |
node.metadata.projax.public.{enabled, description, live_url, source_url, screenshots} |
Item.CreatedAt, Item.UpdatedAt |
node.created_at, node.updated_at 1:1 |
Item.Source |
always "projax" (legacy field; new adapter sets this to maintain consumer assumption) |
Item.SourceRefID |
mai.projects.id from projax-mai-project edge metadata when present |
ItemLink.ID |
mBrian edge uuid |
ItemLink.ItemID |
edge source_id (the projax-side end) |
ItemLink.RefType |
strip projax- prefix from edge rel (projax-caldav-list → caldav-list) |
ItemLink.RefID |
edge metadata.ref_id OR derived from rel-specific payload (caldav: url; gitea-repo: owner/repo; mai-project: mai_project_id) — see §3 gaps |
ItemLink.Rel |
edge note (free-form annotation) OR a constant per rel-type (e.g. 'contains') |
ItemLink.Metadata |
edge metadata MINUS the ref_id extraction |
ItemLink.EventDate |
edge metadata.event_date (date string parsed) |
ItemLink.CreatedAt |
edge created_at 1:1 |
§1.5 — Views (Phase 5j) — explicitly NOT in slice B
Per m's Q5=(a), projax.views stays projax-resident. All view CRUD methods (ListViews, GetView, GetViewByID, CreateView, UpdateView, DeleteView, TouchView, MostRecentView, ReorderViews) stay on the existing *Store and are NOT part of the adapter interface. The Server struct uses the adapter for items+links and the existing Store for views.
§2 — Adapter interface contract
Defined in store/adapter.go (this branch). Pure projax-shaped structs in/out; zero mBrian type leakage. The existing *store.Store already satisfies this interface (it's just a subset of its public surface) — the compile-time assertion makes that explicit. Slice B impl ships a second satisfier (*MBrianReader) that wraps mBrian access.
// ItemReader is the read-only contract every projax UI handler / aggregator /
// MCP read tool depends on. Slice B implements a second satisfier on top of
// mBrian's MCP/SQL surface.
type ItemReader interface {
// Item lookups
ListAll(ctx context.Context) ([]*Item, error)
GetByID(ctx context.Context, id string) (*Item, error)
GetByPath(ctx context.Context, path string) (*Item, error)
GetByPathOrSlug(ctx context.Context, key string) (*Item, error)
Roots(ctx context.Context) ([]*Item, error)
MaiOrphans(ctx context.Context) ([]*Item, error)
ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error)
Search(ctx context.Context, q string, limit int) ([]*Item, error)
ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error)
AllTags(ctx context.Context) ([]string, error)
// Link lookups
LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error)
LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error)
DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error)
DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error)
RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error)
}
§2.1 — Methods needing edge-walk-derived data
Slice B's mBrian impl must compute these from child_of edges + node fields. Cost is one outbound-edges fetch per node OR one bulk edges-by-rel query per request, depending on how the adapter caches.
Item.Paths— every method returning*Itemor[]*Item.Item.ParentIDs— same.GetByPath— walks edges to resolvedev.paliadto a leaf node.Roots— filter where no outboundchild_ofedge.MaiOrphans—Roots∩metadata.projax.management ⊇ {'mai'}.
§2.2 — Methods needing metadata-unpack
Adapter parses metadata.projax.* on read; writes (slice C) re-serialise. Affected fields: Status, Tags, Management, TimelineExclude, Public + 4 public_* fields, StartTime, EndTime.
§2.3 — Methods needing edge.metadata filters
LinksByType(itemID, refType): WHERE source_id=$1 AND rel = 'projax-' || $2.LinksByRefType(refType): WHERE rel = 'projax-' || $1.DatedLinks(itemID): source_id=$1 AND metadata ? 'event_date'.DatedLinksRange(from, to): metadata->>'event_date' BETWEEN $1 AND $2.RecentDocuments(since, limit): dated links since $1 ORDER BY metadata->>'event_date' DESC LIMIT $2.
mBrian's idx_edges_metadata GIN index already exists (mig 010); these queries are index-eligible.
§3 — Gap flags
Items the known mBrian schema needs to satisfy cleanly. The migration script handles most; flag here for the slice-B impl + the migration worker as cross-check items.
| gap | shape | status |
|---|---|---|
item_links.rel (free-form annotation) preservation |
projax has both a typed ref_type AND a free-form rel text ("contains", "source", etc.) on item_links. mBrian's edge rel is the typed name; the free-form annotation maps to edge.note. Migration must NOT drop the projax rel value. |
Add to m/mBrian#73 §1 edge mapping: source rel → mBrian edges.note. |
ItemLink.RefID semantics per type |
projax ref_id is a typed external pointer (caldav url, gitea owner/repo, gitea-issue id, mai project uuid, bare url). mBrian edges carry the payload in metadata. Need a per-rel-type extraction rule. Suggested: metadata.ref_id for the canonical reference + leaves structured payload alongside (url for caldav, owner/repo for gitea). |
Slice B impl reads back per-rel-type; document in m/mBrian#73 issue for the migration script to write consistently. |
paths text[] recomputation cost |
Adapter computes paths from child_of edge walk per call. For ListAll over ~65 items, one bulk edges-by-rel query joined with the node id set is N rows where N = total child_of edges. Cheap at m's scale; add per-request memoisation. |
Slice B impl. No mBrian-side action. |
AllTags aggregation |
Union of metadata.projax.tags[] across all projax-managed nodes. No mBrian index on metadata-array-element. At m's scale (<200 nodes), full-scan is fine; if we grow, add a derived [tag] node graph (m's Q8 deferred to Phase 7). |
Slice B impl, no mBrian-side action. |
Roots / MaiOrphans predicate |
"No outbound child_of edge" requires a subquery / left-join-where-null pattern. Index-eligible via idx_edges_source_rel on (source_id, rel). |
Slice B impl. |
ItemsCreatedInRange |
Direct over nodes.created_at; trivial. Scoped to metadata.projax_origin IS NOT NULL so non-projax mBrian nodes don't leak into projax surfaces. |
Slice B impl + a metadata GIN query (already indexed). |
Item.Source field expectation |
The legacy Source field on Item reads "projax" everywhere consumers check it (some MCP tools branch on it). Adapter sets a constant. |
Slice B impl detail, no DB action. |
SourceRefID for mai bridge |
When a node has a projax-mai-project edge, expose its metadata.mai_project_id as Item.SourceRefID. Slice D (mai bridge worker) writes these edges. |
Slice B impl reads existing edges; slice D writes new ones. |
ItemLinkWithItem join shape |
Used by DatedLinksRange and RecentDocuments. Adapter does two queries (edges-with-dates + node-by-id batch) + an in-memory join, OR one combined MCP call if mBrian exposes a bulk-edges-with-source-node helper. Both work; pick by perf. |
Slice B impl, no mBrian-side change required. |
| Admin counts (web/admin.go direct Pool) | Three count(*) queries (total items, total mai-managed, total public). Adapter gains Counts(ctx) (AdminCounts, error) — small extension. |
Add to ItemReader interface in slice B (low-risk; constant-return until impl) OR keep as a separate AdminReader interface. Recommend adding to ItemReader for cohesion. |
§4 — Skeleton (this branch)
The Go file store/adapter.go ships in this branch with:
ItemReaderinterface as in §2.var _ ItemReader = (*Store)(nil)compile-time assertion. (Drops in cleanly because*Storealready exposes every method in the contract.)MBrianReaderstruct with stubbed method bodies that returnerrNotImplementedSliceB. Each stub carries a one-line comment naming the §3 gap it depends on (if any) so slice B's impl-fill knows what to look up.var _ ItemReader = (*MBrianReader)(nil)compile-time assertion so the stubs stay aligned with the interface.
go build ./... is green with the skeleton in place. No tests, no behaviour, no mBrian client dependency.
The actual mBrian client wiring (whether MCP-over-stdio, direct Postgres against mbrian.* schema, or the in-process submodule pattern flexsiebels uses) is the first decision slice-B-impl makes; it stays out of this prep step.
§5 — Wiring shape after slice B impl
For reference of the post-slice-B shape (no code in this slice):
// Server struct keeps two readers: ItemReader (slice-B mBrian-backed) +
// existing *Store (views CRUD only).
type Server struct {
Items ItemReader // slice B: MBrianReader; today: *Store
Store *store.Store // views CRUD only after slice B
// ... rest unchanged
}
Every handler that today reads s.Store.ListAll(...) becomes s.Items.ListAll(...). Mechanical rename. Slice B impl ships both — adapter wiring + the rename across handlers — as one diff once the migration completes.
§6 — What's NOT in this prep
- mBrian-MCP client wiring.
- Any test of mBrian-backed behaviour.
- Write-path methods (slice C scope).
- View CRUD migration (Q5=(a) stays projax-resident).
- mai bridge worker (slice D).
- Drop projax tables (slice E).
§7 — References
docs/plans/mbrian-backend-migration.md(onmain) — parent plan.cmd/projax-snapshot/(slice 0, merged at38182df) — input for mBrian's migration.- m/mBrian#73 — mBrian-side schema convention node + migration script (in flight).
store/store.go— current*Storeimplementation; the interface*Storealready satisfies.internal/aggregate/aggregator.go— existingLinkListerinterface precedent (a narrow projection of*Store).