Files
projax/docs/plans/slice-b-adapter-contract.md
mAi 9607d4b307 docs+skeleton: Phase 6 Slice B prep — read-path adapter interface contract
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.
2026-05-29 15:17:24 +02:00

16 KiB
Raw Blame History

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) add Counts(ctx) (AdminCounts, error) to the adapter, (b) compute in-handler from ListAll. Adapter pick.
  • web/bulk.go handleBulkApply — multi-row UPDATE inside a tx. Pure write; slice C.
  • web/links.go handleSetEventDate — single UPDATE on item_links.event_date. Pure write; slice C.

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-listcaldav-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 *Item or []*Item.
  • Item.ParentIDs — same.
  • GetByPath — walks edges to resolve dev.paliad to a leaf node.
  • Roots — filter where no outbound child_of edge.
  • MaiOrphansRootsmetadata.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:

  1. ItemReader interface as in §2.
  2. var _ ItemReader = (*Store)(nil) compile-time assertion. (Drops in cleanly because *Store already exposes every method in the contract.)
  3. MBrianReader struct with stubbed method bodies that return errNotImplementedSliceB. 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.
  4. 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 (on main) — parent plan.
  • cmd/projax-snapshot/ (slice 0, merged at 38182df) — input for mBrian's migration.
  • m/mBrian#73 — mBrian-side schema convention node + migration script (in flight).
  • store/store.go — current *Store implementation; the interface *Store already satisfies.
  • internal/aggregate/aggregator.go — existing LinkLister interface precedent (a narrow projection of *Store).