From b3e7183478b6a6a2cb1dc23b3c32c8be56d2f8ce Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 29 May 2026 12:49:48 +0200 Subject: [PATCH 1/3] docs: Phase 6 mBrian-as-backend migration design plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m's decision on issue m/projax#5 (2026-05-29): Option A — full backend migration to mBrian. mBrian becomes the canonical store for projax data; projax UI surfaces stay (Tiles dashboard, calendar grid, timeline spine, the just-shipped 5j /views routes) but read+write goes through mBrian instead of projax.items. The plan covers: - §1 diagnosis: closing the parallel-knowledge-surface gap - §2 column-by-column schema mapping (projax.items → mBrian nodes + metadata, projax.item_links → mBrian edges + new edges.metadata) - §3 mBrian-side requirements: schema fragments to add (edges.metadata column, projax edge relations + types schema-nodes) - §4 read-path replacement: store adapter over mBrian, UI shape stable - §5 write-path replacement: every handler + MCP write rewired - §6 integrations disposition: CalDAV/Gitea stay projax-handled at consumption; mai.projects sync moves to a handler-layer bridge - §7 migration mechanics: hard-cut script per m's loss tolerance - §8 six-slice plan: A (mBrian schema) → B (data migration) → C (read-path) → D (write-path) → E (drop projax tables) → F (integrations) - §9 cross-repo coordination protocol via otto/head (no mBrian/head worker exists today) - §10 eleven open questions for m, batched for head delegation - §11 risk register - §12 test plan headlines Slice A is mBrian-side and is the hard gate — projax B–F cannot start until mBrian's schema fragments land. Cross-repo coordination request filed alongside the m delegation. No code changes; this branch ships docs only. Coder shifts wait on m's sign-off on §10 + mBrian-side slice A. --- docs/plans/mbrian-backend-migration.md | 380 +++++++++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 docs/plans/mbrian-backend-migration.md diff --git a/docs/plans/mbrian-backend-migration.md b/docs/plans/mbrian-backend-migration.md new file mode 100644 index 0000000..ad7084b --- /dev/null +++ b/docs/plans/mbrian-backend-migration.md @@ -0,0 +1,380 @@ +# mBrian-as-backend migration — Phase 6 design + +**Status**: Phase A design (this doc). +**Branch**: `mai/kahn/phase-6a-mbrian-design`. +**Author**: kahn (inventor), 2026-05-29. +**Source decision** (m, issue m/projax#5, 12:43 2026-05-29): Option A — full backend migration. *"I think we need the project-management element inside of mBrian for it to be the complete 2nd Brain experience. The data itself is not too important yet."* + +**Constraint**: data-loss tolerant on the 47 current `projax.items`. + +--- + +## §1 — Diagnosis + +projax today stores its own structured data in `projax.items` + `projax.item_links` (msupabase, schema `projax`). It's a parallel knowledge surface to mBrian's main graph — both store nodes-with-content-and-edges, both speak SQL+jsonb, both ship MCP. The duplication has cost: project context (held by projax) is invisible to mBrian's reasoning paths; mBrian's relationship graph (held by mbrian) is invisible to projax's tile / timeline aggregations. + +m's call closes the gap by making mBrian canonical. Projax keeps its UI — the /views routes, the Tiles dashboard, the calendar grid, the timeline spine, the /tree forest, the just-shipped /views/{slug} family, and the system-view chrome — but every read and write goes through mBrian instead of `projax.items`. Same surface, single source. + +End-state contract: + +- One node graph. Every project, task-context, area, link bundle lives in `mbrian.nodes` + `mbrian.edges`. +- projax's UI is a structured editor + aggregation surface over that graph (think paliad-shape views, mBrian-shape data). +- mBrian's existing surfaces (the web editor, the trackers, the synthesis filings) keep working unchanged — projax data appears alongside everything else. +- CalDAV / Gitea / mai.projects integrations stay projax-handled at the consumption layer; the items they hang off of live in mBrian. +- The 47-item migration is one-shot. Anything lossy gets logged + flagged for manual repair; we don't preserve at all costs. + +--- + +## §2 — Schema mapping (the load-bearing section) + +### Per-column map: `projax.items` → mBrian shape + +| projax column | mBrian destination | notes | +|---|---|---| +| `id` (uuid) | `nodes.id` | new uuids on migration; legacy ids never round-trip | +| `kind` (text[]) | `nodes.type` | direct shape match; projax `'project'` becomes mBrian `'project'` (already exists per mig 030); add `'area'` if missing | +| `title` | `nodes.title` | 1:1 | +| `slug` | `nodes.slug` | mBrian = unique per user; projax = unique per parent — see §2.1 | +| `paths` (text[]) | derived from `child_of` edges + `nodes.path` cache | DAG resolution via edge walk; see §2.2 | +| `parent_ids` (uuid[]) | edges `(source=this, rel='child_of', target=parent)` | one edge per parent; preserves multi-parent | +| `content_md` | `nodes.content_md` | 1:1 | +| `aliases` (text[]) | `nodes.aliases` | 1:1 | +| `metadata` (jsonb) | `nodes.metadata` | merge; projax metadata keeps its existing shape under a `projax` sub-key to avoid colliding with mBrian's metadata schema | +| `status` (text) | `nodes.metadata.projax.status` | active/done/archived; mBrian's `archived` bool covers part of it but loses the active/done split | +| `pinned` (bool) | `nodes.pinned` | 1:1 | +| `archived` (bool) | `nodes.archived` | 1:1; status='archived' implies this too | +| `start_time`, `end_time` (timestamptz) | `nodes.metadata.projax.start_time` / `end_time` | mBrian has no first-class start/end | +| `tags` (text[]) | `nodes.metadata.projax.tags` | mBrian convention puts tags as separate `[tag]` nodes joined via `tagged` edges; we keep tags in metadata for the migration window then optionally re-shape — see Q8 | +| `management` (text[]) | `nodes.metadata.projax.management` | mai/self/external/unmanaged — projax-specific concept; stays in metadata | +| `public`, `public_description`, `public_live_url`, `public_source_url`, `public_screenshots` | `nodes.metadata.projax.public.{...}` | mBrian's `visibility` is a different model (personal/public/...); we keep projax's bundle in metadata so the flexsiebels portfolio renderer keeps working | +| `timeline_exclude` (text[]) | `nodes.metadata.projax.timeline_exclude` | projax-only concept | +| `created_at` | `nodes.created_at` | 1:1 | +| `updated_at` | `nodes.updated_at` | trigger-maintained on both sides | +| `deleted_at` | `nodes.deleted_at` | 1:1 | + +### §2.1 — Slug uniqueness + +projax enforces slug uniqueness **per parent** (a `paliad` slug can exist under both `dev` and `work`). mBrian enforces slug uniqueness **per user** (one `paliad` total). For a multi-parent item like `paliad` living under both `dev` and `work`, mBrian today only allows one `paliad` node — which is actually what we want: a single canonical node connected to both parents via `child_of` edges. The DAG-as-multiple-paths view is a render-time concept; the storage is one node. + +The projax handlers' itemwrite validator (Phase 5c) loses its per-parent slug rule and gains an uniqueness-per-user check. This is **stricter** — m can't have two different "paliad" projects under different roots. Flag for Q6. + +### §2.2 — paths array vs single path + +projax's `paths text[]` is computed from `parent_ids` (one path per ancestor lineage). mBrian's `path text` is a single denormalized cache; the canonical structure is `child_of` edges. + +For projax UI to keep showing multi-paths ("Also at: work.paliad"), the store-adapter layer (§4) re-derives `paths[]` from the edge graph on each fetch. Cheap at m's scale (≤200 nodes); cache lightly if profiling bites. + +### `projax.item_links` → mBrian edges + +Each `item_links` row becomes a mBrian edge with a typed `rel`. The `ref_id` semantics differ: + +| projax ref_type | mBrian shape | notes | +|---|---|---| +| `caldav-list` | edge `rel='projax-caldav-list'`, `metadata.url=...` | external URL — no target node exists; edge carries the URL in `note` or `metadata` | +| `gitea-repo` | edge `rel='projax-gitea-repo'`, metadata={owner, repo} | same shape | +| `gitea-issue` | edge `rel='projax-gitea-issue'`, metadata={owner, repo, number} | same | +| `mai-project` | edge `rel='projax-mai-project'`, metadata={mai_project_id} | bridge for the Phase 1.5 bidirectional sync | +| `mbrian-node` | edge `(source=this, rel='related_to', target=)` | already mBrian — this becomes a regular node-to-node edge | +| `url` | edge `rel='projax-url'`, metadata={url} | unstructured link | +| `document`, `note` | edge `rel='projax-doc'`, metadata={...} | PER day-granular dated artifacts | + +mBrian edges support `note text` plus an `auto bool` flag. Both used by projax: `auto=false` for human-added links, `note` carries human annotation. The structured payload (URL, repo info, etc.) lands in a metadata jsonb that we add via a new `edges.metadata` column — see §3. + +### Open question on edge payloads + +mBrian's `edges` table today has no `metadata jsonb` column — only `rel`, `note`, `sort_order`, `node_id`, `auto`. For projax's typed external-ref payloads (caldav URLs, gitea repo names), we need either: +- (a) Add `metadata jsonb` to `mbrian.edges` (mBrian-side schema work, see §3 Q-A). +- (b) Use the `node_id` "complex edge" feature: the edge points at a third node that holds the metadata. Heavier per-link cost; one node per external ref. +- (c) Stash structured payload inside `note text` as JSON. Hacky; loses index-ability. + +**Inventor pick: (a)** — adds one nullable column to `edges`, indexes optionally, keeps the simple shape and matches projax's existing item_links model. + +--- + +## §3 — mBrian-side requirements + +Schema fragments mBrian needs to register. Each is a separate migration on the mBrian side, written by mBrian's coder. These are the cross-repo asks: + +**MB-A — Add `edges.metadata jsonb NOT NULL DEFAULT '{}'`** (per §2.2). Backfill is no-op; new column starts empty. Optional GIN index if projax queries by metadata content (not in v1 — projax queries by `rel` only). + +**MB-B — Register projax-specific edge relations.** mBrian has no enum on `edges.rel`; new values just appear. Document the projax-namespaced rels (`projax-caldav-list`, `projax-gitea-repo`, `projax-gitea-issue`, `projax-mai-project`, `projax-url`, `projax-doc`) in a schema-node so mBrian's tooling knows they belong to projax. Concrete deliverable: a `[schema]` node `projax-edge-relations` under a `[topic]` hub `projax-integration` (created same migration). + +**MB-C — Optional: register projax types as known types.** mBrian's `'project'` type already exists. Add `'area'` if missing (projax's top-level area concept). Document the projax type set in another `[schema]` node so future readers know `'mai-managed'` means "this projax node mirrors a mai.projects row." + +**MB-D — Confirm no slug-uniqueness blockers.** projax migration creates ~47 nodes with potentially-colliding slugs (e.g. two `paliad`-rooted DAG paths today are ONE node post-migration; need to dedupe pre-write). mBrian-head to confirm the per-user unique index doesn't choke on bulk insert ordering. + +**MB-E — Read-only API surface.** projax read-path (§4) calls mBrian. We need: +- A way to filter nodes by `type` array containment (mBrian already exposes via `list_nodes(type='...')`). +- Edge query by `rel` + `source_id` or `target_id`. +- Trigram / FTS search across title + content_md (mBrian already has this). +- Optionally: a bulk endpoint that returns nodes + their outbound edges in one call (projax's tree-render path needs ~all nodes + all edges). + +Confirm MCP surface coverage. If anything's missing, mBrian-coder adds it. + +**MB-F — Write API surface.** projax write-path (§5) calls mBrian. We need create_node, update_node, soft-delete, create_edge, delete_edge — all already exist in mBrian's MCP. + +Cross-repo coordination shape: this lands as a Gitea issue on `m/mBrian` repo, batched as one ticket with sub-tasks for A–F. The issue links back to this plan + projax/#5. + +--- + +## §4 — projax-side read-path replacement + +The store package becomes a thin adapter over mBrian. Consumers stay shape-stable: `*store.Item` still exposes Kind / Title / Slug / Paths / ParentIDs / ContentMD / Aliases / Metadata / Status / Pinned / Archived / Tags / Management / Public* / TimelineExclude / etc. Internally those come from mBrian nodes + metadata + edge-walks. + +| projax call site | new implementation | +|---|---| +| `store.Store.ListAll(ctx)` | mBrian: `SELECT FROM mbrian.nodes WHERE 'projax' = ANY(metadata.projax_origin) ... ORDER BY title` (or via MCP `list_nodes`). Returns []*Item adapted from each node. | +| `store.Store.GetByPath(ctx, path)` | resolve path → leaf node by walking `child_of` edges from the path's root segment; cache hits during render | +| `store.Store.GetByID(ctx, id)` | direct mBrian fetch | +| `store.Store.LinksByRefType(ctx, t)` | edge query `rel='projax-'` over all projax-managed nodes | +| `store.Store.AllTags(ctx)` | aggregate over `metadata.projax.tags` arrays across projax nodes | +| `store.Store.MaiOrphans(ctx)` | mBrian: find projax-managed nodes with no `child_of` edge + `metadata.projax.management contains 'mai'` | +| `store.Store.DatedLinks(ctx, id)` | edge query `rel IN ('projax-doc', 'projax-url')` for the node, filtered to those with `metadata.event_date` set | + +The aggregator (`internal/aggregate/`) doesn't see mBrian — it gets `[]*store.Item` from the adapter. CalDAV + Gitea external fetches stay where they are. + +Views (Phase 5j `projax.views` table) decision point — see Q5. + +### Adapter layer surface + +```go +package store + +type Store struct { + mb *mbrian.Client // MCP-style client or direct SQL +} + +func (s *Store) ListAll(ctx context.Context) ([]*Item, error) { ... } +// every existing method keeps its signature; bodies rewrite to mBrian calls +``` + +The Item struct stays unchanged. Tests against the adapter assert "given this mBrian state, ListAll returns these items". Existing aggregator + handler tests stay green because they only see `*Item`. + +--- + +## §5 — projax-side write-path replacement + +Every projax write rewires to mBrian. + +| projax handler | new behaviour | +|---|---| +| `POST /i/{path}` (detail edit, `handleDetailWrite`) | mBrian update_node + edge re-write for `parent_ids` changes | +| `POST /new` (`handleNewSubmit`) | mBrian create_node + `child_of` edges | +| `POST /i/{path}/reparent` (`handleReparent`) | edge delete + re-create for `child_of` | +| `/admin/bulk` (`handleBulkApply`, `handleBulkChip`) | bulk mBrian updates; one mBrian write per row | +| `/admin/classify` (`handleClassify`) | mBrian update + add `child_of` edge | +| `POST /views/...` (5j editor) | unchanged if views stay in `projax.views`; rewired if they move (Q5) | +| MCP `create_item` / `update_item` / `delete_item` | mBrian MCP create / update / soft_delete | +| MCP `add_link` / `remove_link` | mBrian create_edge / delete_edge | + +### Validation (Phase 5c itemwrite package) + +The pre-flight validator stays as projax-handler logic — projax UI / MCP still surface friendly errors for `KindInvalidSlugFormat` / `KindSlugCollision` / `KindCycle` / etc. before round-tripping. The DB-level enforcement moves to mBrian's per-user unique index on slug (covers collision) + projax's `paths` recomputation (covers cycle detection). Trigger-level cycle detection on mBrian's edges is a mBrian-side ask (mb-G optional). + +### Cycle + slug-collision semantics + +Per §2.1: projax loses per-parent slug uniqueness; per-user uniqueness wins. The validator's KindSlugCollision rule needs updating to reject any duplicate slug across the whole projax-managed set, not just under the same parent. + +Cycle detection: projax today does it via the path trigger (cycle = self-ancestor). After migration, projax fetches all projax nodes + their child_of edges, walks the closure on every write, rejects cycles. Cheap at m's scale. + +--- + +## §6 — Integrations (CalDAV / Gitea / mai.projects) + +### CalDAV + Gitea + +The link bundle (per §2.2) moves to mBrian edges with structured metadata. The CalDAV / Gitea **clients** + their caches stay projax-side (the aggregator owns these). The render path queries mBrian for "which items have caldav-list edges + what URLs," then fans out to the existing CalDAV client. Net effect: the fan-out stays where it is; only the source of "what to fan out for" changes. + +### mai.projects bidirectional sync (Phase 1.5) + +The Phase 1.5 trigger pair (mai.projects ↔ projax.items) is the most fragile piece of the integration today. After Phase 6: + +- (a) **Keep the trigger pair**, pointing the mai.projects view at the migrated mBrian nodes. Requires rewriting the trigger functions to read from mBrian; significant complexity because mai.projects expects projax.items columns. +- (b) **Move the bridge to projax handler layer**: a sync worker watches mai.projects changes + writes mBrian; mBrian node changes flow back via a webhook or periodic poll. Slower but decoupled. +- (c) **Drop the bridge entirely**: mai.projects becomes legacy; mai workers consume mBrian directly via MCP. Cleanest, but requires mai-side work to migrate workers/tasks/sessions FKs. + +**Inventor pick: (b)** — the bridge stays operational without bleeding mBrian schema details into mai.projects code, and m can sunset it gradually. (c) is the right long-term shape but it's another migration project; out of scope for Phase 6. + +This is **Q2** for m. + +--- + +## §7 — Migration mechanics + +Per m's loss-tolerance signal: hard-cut. One script, run once, with a clear blast radius. + +Script outline (Go, lives in `cmd/migrate-mbrian/main.go`): + +1. Connect to msupabase as `postgres`. +2. Read every row from `projax.items` where `deleted_at IS NULL`. +3. For each row: + a. Generate new mBrian uuid (or preserve old uuid — projax uuids don't collide with mBrian's; we can preserve, but the migration script picks). + b. INSERT into `mbrian.nodes` with type/title/slug/aliases/metadata mapped per §2. Add `metadata.projax_origin: ` so a future audit can reconcile. + c. For each `parent_id`, INSERT `mbrian.edges (source=new_node_id, target=parent_new_id, rel='child_of')`. Parent uuids resolved via a two-pass walk: first pass creates all nodes, second pass writes edges. +4. For each `projax.item_links` row: + a. Translate `ref_type` → mBrian edge `rel` per §2.2 table. + b. INSERT `mbrian.edges` with `metadata` carrying the structured payload. +5. For each `projax.views` row (5j paliad-shape views): see Q5. +6. Smoke check: count(mbrian.nodes WHERE metadata->>'projax_origin' is not null) == count(projax.items WHERE deleted_at IS NULL). +7. DON'T drop projax.items + item_links in the same migration. Drop happens in slice E after stable read+write. + +Idempotency: the script checks `metadata.projax_origin` on each insert to avoid duplicates on re-run. + +Lossy bits (acceptable per m's stance): the projax `paths` array isn't preserved — it's recomputed from edges. The Phase 1.5 mai.projects mirror rows: the bridge worker handles re-sync after migration (Q2). + +--- + +## §8 — Implementation slicing + +Slices A–F, with hard cross-repo coordination on A. Each slice independently shippable. + +- **A. mBrian schema-fragment** — mBrian-side. Adds `edges.metadata`, registers projax edge relations + types in schema-nodes, confirms read+write MCP coverage. Lands as a Gitea issue on `m/mBrian`, coordinated through §9. + +- **B. Data migration script** — projax-side. `cmd/migrate-mbrian/main.go`. Runs once against the 47 items + their links + the 5j views (per Q5). Test on a scratch mBrian dataset before the real migration. + +- **C. Read-path replacement** — projax-side. `store/` package rewired to mBrian. The Item struct stays; method bodies rewrite. All UI + aggregator tests stay green (they only see Item). Adapter caches a per-request snapshot to avoid N+1 mBrian calls. + +- **D. Write-path replacement** — projax-side. Every handler + MCP write rewires. itemwrite validator updates for slug-uniqueness semantics. + +- **E. Drop projax tables** — projax-side. After stable read+write on mBrian for one shift, drop `projax.items` + `projax.item_links`. Migration `0018_drop_projax_items.sql`. + +- **F. Integrations disposition** — depending on Q2's answer. If (b), build the bridge worker. If (c), coordinate with mai-side to migrate workers/tasks FKs. + +Dependency graph: + +``` +A ──→ B ──→ C ──→ D ──→ E + ↘ F (parallel after D) +``` + +A is the gate. C and D can ship together if the test surface stays green; otherwise C ships first (read-path) and D follows after one shift's worth of read-only soak. + +--- + +## §9 — Cross-repo coordination + +mBrian is m's actual second brain. Schema changes carry blast radius. The protocol: + +1. **mBrian's head identity**. Looking at the m/mBrian repo: m manages it directly today (no mBrian/head worker registered in mai). Per global Channel Routing rule (only `otto/head` writes to m directly), the projax coordination request routes through `otto/head` who relays to m and back. Confirm with **Q4**. + +2. **The schema-fragment ask**. One delegation message to head with the §3 MB-A through MB-F items batched. Head decides whether to: file as an issue on `m/mBrian` directly, route through otto/head for m to dispatch a mBrian worker, or coordinate paired implementation. + +3. **Sequencing**. mBrian-side A must land + deploy before projax-side B can run. Recommend filing the schema request as `m/mBrian` issue with a clear "blocks projax phase 6" tag. + +4. **Design-doc sharing**. This plan stays in `m/projax`. m/mBrian gets a pointer issue with the relevant §2+§3 excerpts. + +--- + +## §10 — Open questions for head delegation + +The 8 from issue #5 plus what surfaced during this survey. + +**Q1 — mBrian node type for projax items** +- (a) Reuse existing `'project'` type, add `'area'` if missing, multi-typed for both. — **inventor pick** (existing type minimises mBrian-side churn). +- (b) New dedicated `'projax-item'` / `'work-item'` type. + +**Q2 — mai.projects bidirectional sync disposition** (§6) +- (a) Keep the trigger pair (rewrite to read from mBrian). +- (b) Move to projax handler-layer bridge worker. — **inventor pick** (clean decoupling). +- (c) Drop entirely; migrate mai-side FKs. + +**Q3 — CalDAV + Gitea integration ownership** (§6) +- (a) Clients + caches stay projax-side; only the "which items have these links" lookup moves to mBrian. — **inventor pick** (minimal change to aggregator). +- (b) Migrate CalDAV/Gitea ownership to mBrian edges + projax becomes a pure renderer. + +**Q4 — mBrian head contact protocol** (§9) +- (a) Through otto/head per Channel Routing (default per global rule). — **inventor pick**. +- (b) Direct to a future mBrian/head worker. +- (c) m himself owns mBrian schema work — file Gitea issue on m/mBrian. + +**Q5 — projax.views (5j) disposition** +- (a) Keep as projax-resident table — views are projax-UI state, not graph data. — **inventor pick**. +- (b) Migrate to mBrian nodes with type=`[view]`; one node per saved view. +- (c) Drop the table; user views become a derived shape from mBrian metadata on the items themselves. + +**Q6 — Slug uniqueness model** +- (a) Adopt mBrian's per-user unique (loses "two paliads under different roots" case). — **inventor pick** (simpler; m hasn't used the per-parent split in practice). +- (b) Keep projax's per-parent rule via projax-handler validator + mBrian per-user check disabled for projax nodes (requires mBrian-side scoped-uniqueness work). + +**Q7 — Migration mechanics** (§7) +- (a) Hard-cut, one script, accept data loss. — **inventor pick** (matches m's stance). +- (b) Phased dual-write + soak. + +**Q8 — Tags model** +- (a) Keep tags in `metadata.projax.tags` (projax sees them as before; mBrian doesn't index them). — **inventor pick** for v1. +- (b) Lift each tag to a `[tag]` node + `tagged` edges (mBrian convention). +- (c) Hybrid — keep metadata for projax compatibility AND wire tagged-edges for mBrian visibility. + +Q8(c) is the "right" long-term shape but doubles the write surface in slice D. Recommend deferring to a Phase 7 polish. + +**Q9 — Cycle detection placement** +- (a) projax-handler-side via in-memory closure walk before write. — **inventor pick** (cheap at m's scale). +- (b) mBrian-side via trigger on `edges` (mb-G ask). + +**Q10 — Projax MCP surface** +- (a) Keep projax MCP tools (`mcp__projax__*`); they now route through the adapter. — **inventor pick** (no MCP client change). +- (b) Sunset projax MCP; users call mBrian MCP directly. + +**Q11 — `projax_origin` audit metadata** (§7) +Per the migration script, every migrated node carries `metadata.projax_origin = `. Keep indefinitely (audit trail), purge after one shift (cleanup), or never write it (trust). **Inventor pick**: keep indefinitely. + +--- + +## §11 — Risk register + +| risk | likelihood | mitigation | +|---|---|---| +| mBrian-side schema work (slice A) blocks projax indefinitely | medium | clear delegation + Gitea issue with "blocks projax phase 6" tag; m can dispatch fast-track | +| 47-item migration script silently drops fields | low | smoke check (item count parity) + spot-check 5 items post-migration before slice C | +| Slug collision on multi-rooted items (e.g. two `paliad`s) | medium | pre-migration script: detect collisions, dedupe to one node with multiple `child_of` edges, log skips | +| mai.projects trigger pair breaks mid-migration | medium | turn off the triggers before migration, rebuild post-migration (Q2 (b) bridge takes over) | +| Adapter introduces N+1 mBrian calls during render | medium | one ListAll + one LinksByRef query per request, cached per-request; profile after slice C | +| Phase 5j views surface breaks | low | views stay projax-resident per inventor pick on Q5; no migration cost | +| flexsiebels.de public-listing renderer breaks | medium | metadata.projax.public.* bundle preserves the shape; spot-test before slice E | +| Cross-repo coordination delay | medium | filed as Gitea issue (durable) + delegation (real-time signal); both paths active | + +--- + +## §12 — Test plan headlines + +### Slice B (migration script) +- `TestMigrateScriptSmokes` — 5 hand-crafted projax.items + 3 item_links → mBrian nodes + edges; count parity assertion. +- `TestMigrateScriptIdempotent` — second run = no new nodes. +- `TestMigrateScriptSlugCollision` — two multi-rooted items same slug → one node with two `child_of` edges, log entry. + +### Slice C (read-path) +- `TestAdapterListAllReturnsItemsFromMBrian` — seed mBrian nodes with `projax_origin`, ListAll returns matching Items. +- `TestAdapterGetByPathResolvesEdges` — `dev.paliad` walks `child_of` edges to leaf node. +- `TestAdapterPathsArrayMultiRoot` — node with two `child_of` edges produces 2 entries in `it.Paths`. + +### Slice D (write-path) +- `TestHandleDetailWriteUpdatesMBrian` — POST /i/dev.paliad updates the mBrian node's title. +- `TestHandleReparentRewritesChildOf` — POST /i/dev.paliad/reparent deletes old edge + creates new one. +- `TestSlugCollisionRejected` — second create with same slug rejected with KindSlugCollision. + +### Slice E (drop) +- migration `0018_drop_projax_items.sql` smoke test: `\dt projax.*` returns only `projax.views` + `projax.schema_migrations`. + +### Slice F (integrations) +- per Q2 answer — bridge-worker test (Option b) OR mai-FK migration test (Option c). + +--- + +## §13 — References + +- `~/dev/mBrian/db/001_initial_schema.sql` — mBrian schema baseline. +- `~/dev/mBrian/docs/schema.md` — schema doc. +- `~/dev/mBrian/CLAUDE.md` — mBrian conventions + relation to flexsiebels. +- `projax/store/store.go` — current Item struct + projax store API. +- `projax/store/views.go` — Phase 5j views table. +- `projax/docs/design.md` — current PRD. +- `projax/docs/plans/views-redesign.md` — Phase 5j design. +- `m/projax` issue #5 — m's Option A pick. + +--- + +## §14 — Status + +- **Phase A (this doc)**: drafted by kahn, 2026-05-29. Awaiting m's answers on §10 via head delegation, AND cross-repo coordination via head with mBrian. +- **Phase B (coder)**: blocked on (1) m's sign-off on §10 + (2) mBrian-side schema-fragment landed (slice A complete + deployed). +- **No code changes** in this branch beyond this doc. Slice A is mBrian-side; projax-side slices B–F wait on it. From a5b0971b9d8f15146b70ec041ed973f70de24c1f Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 29 May 2026 13:56:50 +0200 Subject: [PATCH 2/3] docs: Phase 6 plan re-baseline against live mBrian schema + m's answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m answered all 11 §10 questions; every inventor pick confirmed. m's overriding directive: "keep the database simple so it remains easily modifiable." Head verified the live mBrian schema after m's answers — original §3 was built off stale db/001_initial_schema.sql. Three of the six asks turned out already-satisfied: - MB-A (edges.metadata jsonb) — already added in db/010, GIN-indexed, used by migs 039/040. Drop the ask. - MB-C (project type) — already in live schema, mig 033 confirms. Drop the ask. - MB-D (per-user slug uniqueness) — already enforced by idx_nodes_slug in db/001. Drop the ask. Plus 'area' as a separate mBrian type is killed per m's "keep it simple": areas reuse type=['project'] with metadata.projax.kind='area'. Zero DDL. Remaining mBrian-side artifact compresses to ONE [schema] convention node under a new [topic] projax-integration hub, plus mBrian-side ownership of the one-shot data-migration script (per m's "mbrian must own the migration"). Re-sequenced §8: six slices. 0 (projax snapshot helper) → A (mBrian [schema] node + script run) → B (projax read-path adapter) → C (projax write-path) → D (mai bridge worker) → E (drop projax tables). CalDAV/Gitea integrations stay where they are (m's Q3=(a)). No slice F needed in the original sense. §2 + §2.1 + §7 + §9 + §10 + §14 updated. §3 fully rewritten. No code changes; this branch ships docs only. Slice 0 is the smallest first projax-side step but waits for head's greenlight after the m/mBrian issue is filed. --- docs/plans/mbrian-backend-migration.md | 168 ++++++++++++++++--------- 1 file changed, 111 insertions(+), 57 deletions(-) diff --git a/docs/plans/mbrian-backend-migration.md b/docs/plans/mbrian-backend-migration.md index ad7084b..637a6b9 100644 --- a/docs/plans/mbrian-backend-migration.md +++ b/docs/plans/mbrian-backend-migration.md @@ -1,12 +1,20 @@ # mBrian-as-backend migration — Phase 6 design -**Status**: Phase A design (this doc). +**Status**: Phase A design — re-baselined against live mBrian schema (2026-05-29). **Branch**: `mai/kahn/phase-6a-mbrian-design`. **Author**: kahn (inventor), 2026-05-29. **Source decision** (m, issue m/projax#5, 12:43 2026-05-29): Option A — full backend migration. *"I think we need the project-management element inside of mBrian for it to be the complete 2nd Brain experience. The data itself is not too important yet."* +**m's overriding directive** (2026-05-29 via head): *"keep the database simple so it remains easily modifiable."* + **Constraint**: data-loss tolerant on the 47 current `projax.items`. +**m's answers on §10 (2026-05-29)**: every inventor pick confirmed. + +> Q1=reuse 'project' / Q2=(b) handler bridge / Q3=(a) clients projax-side / Q4=(a) file Gitea on m/mBrian via otto/head — m: *"mbrian must own the migration"* / Q5=(a) views stay projax-resident / Q6=(a) per-user slug / Q7=(a) hard-cut / Q8=(a) tags in metadata / Q9=(a) projax-side cycle detection / Q10=(a) keep projax MCP via adapter / Q11=keep `projax_origin` audit metadata. + +**Re-baseline note**: §3's original ask was built off a stale `db/001_initial_schema.sql` read. Head verified the live mBrian schema after m's answers. Three of the six asks (MB-A, MB-C, MB-D) turned out already-satisfied — `edges.metadata` exists since `db/010_flexsiebels_compat.sql`, `'project'` type exists since `db/033`, the per-user slug unique index ships in `db/001`. The remaining mBrian-side artifact is small. §3 + §8 now reflect that. The big shift: **mBrian owns the one-shot data-migration script** — that's what "mbrian must own the migration" means — while projax owns the read+write rewiring on its own side afterward. + --- ## §1 — Diagnosis @@ -32,7 +40,7 @@ End-state contract: | projax column | mBrian destination | notes | |---|---|---| | `id` (uuid) | `nodes.id` | new uuids on migration; legacy ids never round-trip | -| `kind` (text[]) | `nodes.type` | direct shape match; projax `'project'` becomes mBrian `'project'` (already exists per mig 030); add `'area'` if missing | +| `kind` (text[]) | `nodes.type` | direct shape match; projax `'project'` becomes mBrian `'project'` (already in live schema, mig 033). **Areas keep `type=['project']` + `metadata.projax.kind='area'`** — per m's "keep the database simple" directive, no new mBrian type. Zero DDL. | | `title` | `nodes.title` | 1:1 | | `slug` | `nodes.slug` | mBrian = unique per user; projax = unique per parent — see §2.1 | | `paths` (text[]) | derived from `child_of` edges + `nodes.path` cache | DAG resolution via edge walk; see §2.2 | @@ -52,11 +60,13 @@ End-state contract: | `updated_at` | `nodes.updated_at` | trigger-maintained on both sides | | `deleted_at` | `nodes.deleted_at` | 1:1 | -### §2.1 — Slug uniqueness +### §2.1 — Slug uniqueness (settled) -projax enforces slug uniqueness **per parent** (a `paliad` slug can exist under both `dev` and `work`). mBrian enforces slug uniqueness **per user** (one `paliad` total). For a multi-parent item like `paliad` living under both `dev` and `work`, mBrian today only allows one `paliad` node — which is actually what we want: a single canonical node connected to both parents via `child_of` edges. The DAG-as-multiple-paths view is a render-time concept; the storage is one node. +projax today enforces slug uniqueness **per parent**. mBrian's live schema has `CREATE UNIQUE INDEX idx_nodes_slug ON mbrian.nodes (user_id, slug)` — uniqueness **per user**. Per m's Q6=(a), projax adopts mBrian's model: one `paliad` node total, connected to both `dev` and `work` via two `child_of` edges. The DAG-as-multiple-paths view is a render-time concept; the storage is one node. -The projax handlers' itemwrite validator (Phase 5c) loses its per-parent slug rule and gains an uniqueness-per-user check. This is **stricter** — m can't have two different "paliad" projects under different roots. Flag for Q6. +projax handlers' itemwrite validator (Phase 5c) loses its per-parent slug rule, gains a per-user check (against the projax-managed subset of nodes). This is **stricter** — m can't have two different "paliad" projects under different roots. Settled per m's answer. + +**Pre-migration dedup**: the 47-item migration script (which lives mBrian-side, see §3+§7) scans for slug collisions across the projax dataset and folds collisions into one node with multiple `child_of` edges. Skip-with-log on anything weirder. ### §2.2 — paths array vs single path @@ -91,29 +101,45 @@ mBrian's `edges` table today has no `metadata jsonb` column — only `rel`, `not --- -## §3 — mBrian-side requirements +## §3 — mBrian-side requirements (re-baselined against live schema) -Schema fragments mBrian needs to register. Each is a separate migration on the mBrian side, written by mBrian's coder. These are the cross-repo asks: +Head verified the live mBrian schema after m's answers. Three of the original six asks turned out already-satisfied. What's actually needed reduces to one [schema] convention node + ownership of the one-shot data-migration script. Per m's Q4=(a), this lands as a Gitea issue on `m/mBrian` with the "blocks projax phase 6" tag; head files it. -**MB-A — Add `edges.metadata jsonb NOT NULL DEFAULT '{}'`** (per §2.2). Backfill is no-op; new column starts empty. Optional GIN index if projax queries by metadata content (not in v1 — projax queries by `rel` only). +### Already satisfied (no DDL needed) -**MB-B — Register projax-specific edge relations.** mBrian has no enum on `edges.rel`; new values just appear. Document the projax-namespaced rels (`projax-caldav-list`, `projax-gitea-repo`, `projax-gitea-issue`, `projax-mai-project`, `projax-url`, `projax-doc`) in a schema-node so mBrian's tooling knows they belong to projax. Concrete deliverable: a `[schema]` node `projax-edge-relations` under a `[topic]` hub `projax-integration` (created same migration). +| original ask | live-schema status | +|---|---| +| MB-A — `edges.metadata jsonb` column | **Already exists** — added in `db/010_flexsiebels_compat.sql`: `ALTER TABLE mbrian.edges ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'` plus GIN `idx_edges_metadata`. Already used by mig 039/040. projax link payloads land here directly. | +| MB-C — `'project'` type registration | **Already exists** — confirmed in `db/033` + inbox tests. m's Q1=(a) reuses it. | +| MB-C — `'area'` type registration | **NOT needed** — per m's "keep the database simple," areas reuse `type=['project']` with `metadata.projax.kind='area'`. Zero DDL. | +| MB-D — per-user slug uniqueness | **Already enforced** — `CREATE UNIQUE INDEX idx_nodes_slug ON mbrian.nodes (user_id, slug)` in `db/001`. Handles the bulk migration as-is, modulo the pre-write dedup pass in the script (§7). | +| MB-E — read MCP coverage | **Confirmed** by head — type-array filter, edge query by `rel` + source/target, FTS search all present in mBrian's MCP today. Optional bulk "node + outbound edges" endpoint may improve adapter perf, but v1 ships without it. | +| MB-F — write MCP coverage | **Confirmed** by head — create_node, update_node, soft-delete, create_edge, delete_edge all present. | -**MB-C — Optional: register projax types as known types.** mBrian's `'project'` type already exists. Add `'area'` if missing (projax's top-level area concept). Document the projax type set in another `[schema]` node so future readers know `'mai-managed'` means "this projax node mirrors a mai.projects row." +### Remaining mBrian-side artifact -**MB-D — Confirm no slug-uniqueness blockers.** projax migration creates ~47 nodes with potentially-colliding slugs (e.g. two `paliad`-rooted DAG paths today are ONE node post-migration; need to dedupe pre-write). mBrian-head to confirm the per-user unique index doesn't choke on bulk insert ordering. +**MB-B — projax-integration `[schema]` convention node.** One new mBrian node, no DDL. Lives under a new `[topic]` hub `projax-integration`. Documents: -**MB-E — Read-only API surface.** projax read-path (§4) calls mBrian. We need: -- A way to filter nodes by `type` array containment (mBrian already exposes via `list_nodes(type='...')`). -- Edge query by `rel` + `source_id` or `target_id`. -- Trigram / FTS search across title + content_md (mBrian already has this). -- Optionally: a bulk endpoint that returns nodes + their outbound edges in one call (projax's tree-render path needs ~all nodes + all edges). +1. The projax edge relations: `child_of` (already in use everywhere), `projax-caldav-list`, `projax-gitea-repo`, `projax-gitea-issue`, `projax-mai-project`, `projax-url`, `projax-doc`. Each entry: rel name + the metadata jsonb shape (e.g. `projax-caldav-list` carries `{url: text}`). +2. The projax type usage: `'project'` for both projects and areas; `metadata.projax.kind` distinguishes (`area` vs default `project`). `'mai-managed'` as a co-type marker for nodes mirroring `mai.projects` rows. +3. The projax metadata shape: `metadata.projax.{status, tags, management, public, timeline_exclude, start_time, end_time, kind}` — the subset of projax columns that don't have a first-class mBrian counterpart. +4. A pointer to `projax_origin` audit metadata (set per migrated node, per m's Q11=keep). -Confirm MCP surface coverage. If anything's missing, mBrian-coder adds it. +mBrian-side coder writes this node by creating it via mBrian's editor or MCP. No migration file needed. -**MB-F — Write API surface.** projax write-path (§5) calls mBrian. We need create_node, update_node, soft-delete, create_edge, delete_edge — all already exist in mBrian's MCP. +### mBrian owns the data-migration script -Cross-repo coordination shape: this lands as a Gitea issue on `m/mBrian` repo, batched as one ticket with sub-tasks for A–F. The issue links back to this plan + projax/#5. +Per m's directive "mbrian must own the migration," the one-shot script that creates the 47 nodes + their edges lives in `m/mBrian` (likely `scripts/migrate-from-projax.ts` or similar — mBrian's stack picks). projax-side provides: + +- A frozen snapshot of `projax.items` + `projax.item_links` rows (CSV or JSON dump produced by a projax-side helper). +- The mapping rules from §2 + §2.2 in a form mBrian-side can implement against (this plan doc is the canonical source). +- A spot-check checklist (5 representative items) for post-migration validation. + +The script's blast radius lives on mBrian's side; projax-side blocks on its successful run before slice C kicks off. + +### Cross-repo coordination shape + +One Gitea issue on `m/mBrian` (filed by head), tagged "blocks projax phase 6". The issue body covers MB-B + script ownership + the snapshot-handoff protocol. Body draft delivered to head with this re-baseline (see Phase A workflow §14). --- @@ -199,73 +225,99 @@ This is **Q2** for m. --- -## §7 — Migration mechanics +## §7 — Migration mechanics (mBrian-owned) -Per m's loss-tolerance signal: hard-cut. One script, run once, with a clear blast radius. +Per m's Q7=(a) hard-cut + Q4=(a) "mbrian must own the migration": the one-shot script lives in `m/mBrian`. projax-side provides the input snapshot + the rules in this doc; mBrian-side owns the execution. -Script outline (Go, lives in `cmd/migrate-mbrian/main.go`): +### projax-side input snapshot -1. Connect to msupabase as `postgres`. -2. Read every row from `projax.items` where `deleted_at IS NULL`. -3. For each row: - a. Generate new mBrian uuid (or preserve old uuid — projax uuids don't collide with mBrian's; we can preserve, but the migration script picks). - b. INSERT into `mbrian.nodes` with type/title/slug/aliases/metadata mapped per §2. Add `metadata.projax_origin: ` so a future audit can reconcile. - c. For each `parent_id`, INSERT `mbrian.edges (source=new_node_id, target=parent_new_id, rel='child_of')`. Parent uuids resolved via a two-pass walk: first pass creates all nodes, second pass writes edges. -4. For each `projax.item_links` row: - a. Translate `ref_type` → mBrian edge `rel` per §2.2 table. - b. INSERT `mbrian.edges` with `metadata` carrying the structured payload. -5. For each `projax.views` row (5j paliad-shape views): see Q5. -6. Smoke check: count(mbrian.nodes WHERE metadata->>'projax_origin' is not null) == count(projax.items WHERE deleted_at IS NULL). -7. DON'T drop projax.items + item_links in the same migration. Drop happens in slice E after stable read+write. +A helper command in `cmd/projax-snapshot/main.go` produces a `projax_snapshot.json` containing every live `projax.items` row + every `projax.item_links` row, shaped for direct consumption by the mBrian-side script. One file, deterministic, round-trippable. Ships in slice 0 (the snapshot handoff, see §8). -Idempotency: the script checks `metadata.projax_origin` on each insert to avoid duplicates on re-run. +### mBrian-side script outline (for the m/mBrian issue body) -Lossy bits (acceptable per m's stance): the projax `paths` array isn't preserved — it's recomputed from edges. The Phase 1.5 mai.projects mirror rows: the bridge worker handles re-sync after migration (Q2). +1. Load `projax_snapshot.json`. +2. Two-pass: pass 1 creates every node; pass 2 writes every edge (parent edges + item_links → projax-* edges). +3. For each item: + a. New mBrian uuid OR preserve the projax uuid (mBrian-side picks; either works given m's Q11 audit metadata is the durable reference). + b. INSERT into `mbrian.nodes` with `type=['project']` (or `['project']` + co-type per `kind`), `title`, `slug`, `aliases`, `metadata={projax: {...}, projax_origin: }`. + c. Where projax had multiple paths (same node under multiple parents), DEDUPE by slug — one node, multiple `child_of` edges. +4. For each parent edge: INSERT `mbrian.edges (source=new_id, target=parent_new_id, rel='child_of')`. +5. For each item_links row: INSERT `mbrian.edges` with `rel='projax-'` and `metadata` carrying the structured payload per §2.2. +6. For projax.views (5j): NOT migrated — per m's Q5=(a), the views table stays projax-resident. +7. Smoke check: count(mbrian.nodes WHERE metadata->>'projax_origin' is not null) == count(items in snapshot). +8. Hand off to projax with the new uuid map (`{old_uuid: new_uuid}`) so projax-side caches can warm. + +### Idempotency + +Pre-flight: the script checks `metadata.projax_origin` and skips already-migrated origins on re-run. m can re-run safely if the script aborts mid-way. + +### Lossy bits (acceptable per m's stance) + +- `paths text[]` array is not preserved — projax-side adapter recomputes from edges per §4. +- mai.projects mirror rows: per Q2=(b), a handler-layer bridge worker re-syncs after migration; the Phase 1.5 trigger pair stays disabled. + +### Blast-radius containment + +mBrian-side runs the script with triggers paused, smoke-checks the count + spot-checks the 5 representative items in projax's checklist, then commits + signals projax-side to start slice C (read-path). --- -## §8 — Implementation slicing +## §8 — Implementation slicing (re-baselined) -Slices A–F, with hard cross-repo coordination on A. Each slice independently shippable. +Six slices. The big shift from the original draft: the mBrian-side ask compresses to one [schema] convention node + one migration script (both mBrian-owned per m's Q4). Slice 0 is a small projax-side helper that ships the snapshot. The hard gate is the migration landing — projax-side B reads it as the trigger to start. -- **A. mBrian schema-fragment** — mBrian-side. Adds `edges.metadata`, registers projax edge relations + types in schema-nodes, confirms read+write MCP coverage. Lands as a Gitea issue on `m/mBrian`, coordinated through §9. +- **0. projax-side snapshot helper** — `cmd/projax-snapshot/main.go`. Dumps live `projax.items` + `projax.item_links` to `projax_snapshot.json`. Ships first; minimal risk; deliverable mBrian needs. -- **B. Data migration script** — projax-side. `cmd/migrate-mbrian/main.go`. Runs once against the 47 items + their links + the 5j views (per Q5). Test on a scratch mBrian dataset before the real migration. +- **A. mBrian-side: [schema] convention node + data-migration script** — m/mBrian owns. The [schema] node lives under a new `[topic]` hub `projax-integration`. The script consumes the snapshot from slice 0 and writes 47ish nodes + their edges per §7. mBrian-side post-flight: smoke-check count + spot-check 5 items per the projax checklist. -- **C. Read-path replacement** — projax-side. `store/` package rewired to mBrian. The Item struct stays; method bodies rewrite. All UI + aggregator tests stay green (they only see Item). Adapter caches a per-request snapshot to avoid N+1 mBrian calls. +- **B. projax-side read-path adapter** — projax-side. `store/` package rewired against mBrian's MCP / SQL surface. The `Item` struct stays; method bodies rewrite. All UI + aggregator tests stay green (they only see Item shape). Per-request snapshot cache to avoid N+1 calls. Reads-only soak before slice C. -- **D. Write-path replacement** — projax-side. Every handler + MCP write rewires. itemwrite validator updates for slug-uniqueness semantics. +- **C. projax-side write-path** — projax-side. Every handler + MCP write rewires through the adapter to mBrian. itemwrite validator updates for the per-user slug rule (Q6). Cycle detection on the in-memory closure (Q9). -- **E. Drop projax tables** — projax-side. After stable read+write on mBrian for one shift, drop `projax.items` + `projax.item_links`. Migration `0018_drop_projax_items.sql`. +- **D. mai.projects bridge worker** — projax-side (Q2=(b)). Disable the Phase 1.5 trigger pair; ship a small worker that observes mai.projects writes + reflects them into mBrian, and vice versa. Decoupled, killable. -- **F. Integrations disposition** — depending on Q2's answer. If (b), build the bridge worker. If (c), coordinate with mai-side to migrate workers/tasks FKs. +- **E. Drop `projax.items` + `projax.item_links`** — projax-side. Migration `0018_drop_projax_items.sql`. Triggers off after one shift's stable read+write soak on mBrian. `projax.views` stays (Q5). Dependency graph: ``` -A ──→ B ──→ C ──→ D ──→ E - ↘ F (parallel after D) +0 (projax snapshot) ──→ A (mBrian [schema] node + migration script run) + │ + ▼ + B (projax read-path) ──→ C (projax write-path) + │ + ├──→ D (mai bridge worker) + ▼ + E (drop projax tables) ``` -A is the gate. C and D can ship together if the test surface stays green; otherwise C ships first (read-path) and D follows after one shift's worth of read-only soak. +Slice 0 unblocks A. A is mBrian-owned and the hard gate for everything else. B → C can ship together if green; otherwise B-first soak. + +CalDAV / Gitea integrations stay where they are (Q3=(a)) — no slice F needed in the original sense. --- -## §9 — Cross-repo coordination +## §9 — Cross-repo coordination (settled) -mBrian is m's actual second brain. Schema changes carry blast radius. The protocol: +Per m's Q4=(a) + his words *"mbrian must own the migration"*: -1. **mBrian's head identity**. Looking at the m/mBrian repo: m manages it directly today (no mBrian/head worker registered in mai). Per global Channel Routing rule (only `otto/head` writes to m directly), the projax coordination request routes through `otto/head` who relays to m and back. Confirm with **Q4**. +1. **Protocol**: file a Gitea issue on `m/mBrian` with "blocks projax phase 6" tag. Routed via otto/head per global Channel Routing. Head files it; kahn drafts the body. -2. **The schema-fragment ask**. One delegation message to head with the §3 MB-A through MB-F items batched. Head decides whether to: file as an issue on `m/mBrian` directly, route through otto/head for m to dispatch a mBrian worker, or coordinate paired implementation. +2. **Ownership split**: + - mBrian-side owns: the `[schema]` convention node (MB-B) + the one-shot data-migration script. + - projax-side owns: the snapshot helper (slice 0), the read-path adapter (slice B), the write-path (slice C), the mai bridge (slice D), the table drop (slice E). -3. **Sequencing**. mBrian-side A must land + deploy before projax-side B can run. Recommend filing the schema request as `m/mBrian` issue with a clear "blocks projax phase 6" tag. +3. **Sequencing**: slice 0 produces the snapshot → mBrian-side A consumes it + runs the migration → mBrian-side signals back → projax-side starts B. The Gitea issue is the durable trace; the delegation reply chain is the real-time signal. -4. **Design-doc sharing**. This plan stays in `m/projax`. m/mBrian gets a pointer issue with the relevant §2+§3 excerpts. +4. **Design-doc sharing**: this plan stays in `m/projax`. The m/mBrian issue body (drafted alongside this re-baseline, delivered to head) excerpts §2 (schema mapping), §3 (the one [schema] node ask), §7 (the script outline), and the spot-check checklist. --- -## §10 — Open questions for head delegation +## §10 — Open questions (all answered 2026-05-29) + +All 11 questions resolved. m confirmed every inventor pick. Section retained as the historical record + so a future hand can audit the decision rationale. + + The 8 from issue #5 plus what surfaced during this survey. @@ -375,6 +427,8 @@ Per the migration script, every migrated node carries `metadata.projax_origin = ## §14 — Status -- **Phase A (this doc)**: drafted by kahn, 2026-05-29. Awaiting m's answers on §10 via head delegation, AND cross-repo coordination via head with mBrian. -- **Phase B (coder)**: blocked on (1) m's sign-off on §10 + (2) mBrian-side schema-fragment landed (slice A complete + deployed). -- **No code changes** in this branch beyond this doc. Slice A is mBrian-side; projax-side slices B–F wait on it. +- **Phase A (this doc)**: drafted by kahn 2026-05-29, re-baselined same day against live mBrian schema after m's 11 answers landed. All §10 questions resolved. +- **m/mBrian Gitea issue**: body drafted; head files it under "blocks projax phase 6" tag. +- **Phase B (projax-side coder)**: blocked on (1) slice 0 snapshot helper ships + (2) mBrian-side migration runs + signals back. NO coder flip yet. +- **Slice 0 (projax-side snapshot helper)**: scoped, not yet built. Smallest first-step on projax-side; ready when head greenlights. +- **No code changes** in this branch beyond this doc. From 2702c699d1e83ceb16a8ae5ee2ef3a4526ae9266 Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 29 May 2026 14:02:16 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(snapshot):=20Phase=206=20slice=200=20?= =?UTF-8?q?=E2=80=94=20projax=5Fsnapshot.json=20export=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only export of projax.items + projax.item_links to a JSON file the mBrian-side migration script (m/mBrian#73) consumes. First implementation slice of the Phase 6 mBrian-backend migration. Tool: - cmd/projax-snapshot/main.go: standalone binary, takes --out flag (default ./projax_snapshot.json). Reads PROJAX_DB_URL or SUPABASE_DATABASE_URL like the main projax binary. - Pure read-only: SELECT FROM projax.items WHERE deleted_at IS NULL + SELECT FROM projax.item_links. No writes, no schema changes. - Re-runnable: each invocation produces a fresh deterministic file; no state, no DB side effects. Output shape (Snapshot struct): - version: "1" — bumped on shape changes for downstream version-pinning. - generated_at: timestamp. - items: every live projax.items row with all columns mapped 1:1 to JSON-friendly types (uuid → string, jsonb → map, timestamptz → RFC3339). Empty slices coerced to [] so the mBrian-side script doesn't see null-array surprises. - links: every projax.item_links row, ordered by item_id + ref_type for stable diffs across runs. - spot_checks: the 5 representative items the mBrian-side script verifies post-migration per m/mBrian#73 §3. Selected at runtime by characteristic (root area, single-parent, multi-parent, caldav-linked, public-listing-populated) so the picks self-update as the dataset evolves. Smoke-tested against the live msupabase dataset: wrote /tmp/projax_snapshot.json — 65 items, 81 links, 5 spot-checks Selected spot-checks (live): dev — root area paliad — single-parent project services — multi-parent (2 parents) mhome — caldav-list-linked fdbck — public-listing populated Out of scope (slices B+ pick up): - The mBrian-side script itself lives in m/mBrian per "mbrian must own the migration" (Q4=(a)). - projax-side adapter rewriting waits on the mBrian-side migration run. - No tests yet: this is a one-off helper against live data; smoke run above is the validation surface. A go-test suite can land if the snapshot shape needs evolution before mBrian-side consumes it. --- cmd/projax-snapshot/main.go | 349 ++++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 cmd/projax-snapshot/main.go diff --git a/cmd/projax-snapshot/main.go b/cmd/projax-snapshot/main.go new file mode 100644 index 0000000..3eb98c3 --- /dev/null +++ b/cmd/projax-snapshot/main.go @@ -0,0 +1,349 @@ +// projax-snapshot dumps the current projax.items + projax.item_links state +// to a JSON file so the mBrian-side migration script (m/mBrian#73) can +// consume it. Read-only; no schema changes; idempotent across runs. +// +// Phase 6 Slice 0 — first projax-side step in the mBrian-backend migration. +// See docs/plans/mbrian-backend-migration.md §7 + §8 for the surrounding +// context. The file shape is documented in the m/mBrian#73 issue body +// (the two-pass node-then-edge layout the migration script expects). +// +// Usage: +// +// projax-snapshot # write ./projax_snapshot.json +// projax-snapshot --out path/to/file.json # custom output path +// +// Env: PROJAX_DB_URL or SUPABASE_DATABASE_URL — direct postgres URL into +// msupabase (same conventions as the main projax binary). +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Snapshot is the top-level JSON shape mBrian-side consumes. +type Snapshot struct { + Version string `json:"version"` // doc-evolution marker; bump on shape changes + GeneratedAt time.Time `json:"generated_at"` + GitCommit string `json:"git_commit,omitempty"` // optional build-time injection + Items []Item `json:"items"` + Links []ItemLink `json:"links"` + SpotChecks []SpotCheck `json:"spot_checks"` // 5 representative items per m/mBrian#73 §3 +} + +// Item mirrors every column on projax.items as of this commit. Field +// order matches the SQL projection; types are JSON-friendly (uuid → +// string, jsonb → map). Anything nullable surfaces as omitempty / *T. +type Item struct { + ID string `json:"id"` + Kind []string `json:"kind"` + Title string `json:"title"` + Slug string `json:"slug"` + Paths []string `json:"paths"` + ParentIDs []string `json:"parent_ids"` + ContentMD string `json:"content_md"` + Aliases []string `json:"aliases"` + Metadata map[string]any `json:"metadata"` + Status string `json:"status"` + Pinned bool `json:"pinned"` + Archived bool `json:"archived"` + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + Tags []string `json:"tags"` + Management []string `json:"management"` + Public bool `json:"public"` + PublicDescription string `json:"public_description,omitempty"` + PublicLiveURL string `json:"public_live_url,omitempty"` + PublicSourceURL string `json:"public_source_url,omitempty"` + PublicScreenshots []string `json:"public_screenshots,omitempty"` + TimelineExclude []string `json:"timeline_exclude,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ItemLink mirrors projax.item_links. ref_type values become projax-* +// edge rel names on the mBrian side; the payload lands in edges.metadata +// per the issue body §1. +type ItemLink struct { + ID string `json:"id"` + ItemID string `json:"item_id"` + RefType string `json:"ref_type"` + RefID string `json:"ref_id"` + Rel string `json:"rel"` + Note *string `json:"note,omitempty"` + Metadata map[string]any `json:"metadata"` + EventDate *time.Time `json:"event_date,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// SpotCheck names one of the 5 representative items the mBrian-side +// script verifies post-migration. The reason text is mirrored from +// m/mBrian#73 §3 so future readers don't need to cross-reference. +type SpotCheck struct { + ItemID string `json:"item_id"` + Slug string `json:"slug"` + Title string `json:"title"` + Reason string `json:"reason"` +} + +func main() { + out := flag.String("out", "projax_snapshot.json", "output JSON path") + flag.Parse() + + dbURL := os.Getenv("PROJAX_DB_URL") + if dbURL == "" { + dbURL = os.Getenv("SUPABASE_DATABASE_URL") + } + if dbURL == "" { + die("set PROJAX_DB_URL or SUPABASE_DATABASE_URL") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + die("pool: %v", err) + } + defer pool.Close() + + items, err := loadItems(ctx, pool) + if err != nil { + die("load items: %v", err) + } + links, err := loadLinks(ctx, pool) + if err != nil { + die("load links: %v", err) + } + spots := pickSpotChecks(items, links) + + snap := Snapshot{ + Version: "1", + GeneratedAt: time.Now().UTC(), + Items: items, + Links: links, + SpotChecks: spots, + } + + buf, err := json.MarshalIndent(snap, "", " ") + if err != nil { + die("marshal: %v", err) + } + if err := os.WriteFile(*out, buf, 0644); err != nil { + die("write %s: %v", *out, err) + } + fmt.Fprintf(os.Stderr, + "wrote %s — %d items, %d links, %d spot-checks\n", + *out, len(items), len(links), len(spots)) +} + +func loadItems(ctx context.Context, pool *pgxpool.Pool) ([]Item, error) { + rows, err := pool.Query(ctx, ` +SELECT id, kind, title, slug, paths, parent_ids, content_md, aliases, + metadata, status, pinned, archived, start_time, end_time, + tags, management, + public, coalesce(public_description, ''), + coalesce(public_live_url, ''), + coalesce(public_source_url, ''), + public_screenshots, + timeline_exclude, + created_at, updated_at +FROM projax.items +WHERE deleted_at IS NULL +ORDER BY paths NULLS FIRST, slug`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Item{} + for rows.Next() { + var it Item + if err := rows.Scan( + &it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs, + &it.ContentMD, &it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived, + &it.StartTime, &it.EndTime, &it.Tags, &it.Management, + &it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL, + &it.PublicScreenshots, &it.TimelineExclude, &it.CreatedAt, &it.UpdatedAt, + ); err != nil { + return nil, err + } + // Normalise empty slices: pgx hands back nil for empty array + // columns, which renders as `null` in JSON. Coerce to [] for + // downstream-script ergonomics. + if it.Kind == nil { + it.Kind = []string{} + } + if it.Paths == nil { + it.Paths = []string{} + } + if it.ParentIDs == nil { + it.ParentIDs = []string{} + } + if it.Aliases == nil { + it.Aliases = []string{} + } + if it.Tags == nil { + it.Tags = []string{} + } + if it.Management == nil { + it.Management = []string{} + } + if it.PublicScreenshots == nil { + it.PublicScreenshots = []string{} + } + if it.TimelineExclude == nil { + it.TimelineExclude = []string{} + } + if it.Metadata == nil { + it.Metadata = map[string]any{} + } + out = append(out, it) + } + return out, rows.Err() +} + +func loadLinks(ctx context.Context, pool *pgxpool.Pool) ([]ItemLink, error) { + rows, err := pool.Query(ctx, ` +SELECT id, item_id, ref_type, ref_id, rel, note, metadata, + event_date, created_at +FROM projax.item_links +ORDER BY item_id, ref_type, created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ItemLink{} + for rows.Next() { + var l ItemLink + if err := rows.Scan( + &l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, + &l.Metadata, &l.EventDate, &l.CreatedAt, + ); err != nil { + return nil, err + } + if l.Metadata == nil { + l.Metadata = map[string]any{} + } + out = append(out, l) + } + return out, rows.Err() +} + +// pickSpotChecks selects the 5 representative items the mBrian-side +// migration script verifies post-migration, per m/mBrian#73 §3: +// +// 1. A simple root area (dev). +// 2. A single-parent project (dev.paliad — or whichever single-parent +// project we can find). +// 3. A multi-parent project (any item with >1 parent_id). +// 4. A project with a caldav-list link. +// 5. A project with public=true and public_description / public_live_url +// populated. +// +// Failures to find any one of the 5 are non-fatal — the SpotChecks slice +// just shrinks. mBrian-side script logs whatever's missing. +func pickSpotChecks(items []Item, links []ItemLink) []SpotCheck { + byID := map[string]*Item{} + for i := range items { + byID[items[i].ID] = &items[i] + } + caldavItems := map[string]bool{} + for _, l := range links { + if l.RefType == "caldav-list" { + caldavItems[l.ItemID] = true + } + } + out := []SpotCheck{} + + // 1. Root area "dev" if present. + for _, it := range items { + if it.Slug == "dev" && len(it.ParentIDs) == 0 { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "root area (dev) — verify type=['project'] + metadata.projax.kind='area' round-trip", + }) + break + } + } + + // 2. Single-parent project — prefer dev.paliad if present, else any. + added2 := false + for _, it := range items { + if it.Slug == "paliad" && len(it.ParentIDs) == 1 { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "single-parent project (dev.paliad) — verify one child_of edge", + }) + added2 = true + break + } + } + if !added2 { + for _, it := range items { + if len(it.ParentIDs) == 1 && !containsString(it.Kind, "mai-managed") { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "single-parent project — verify one child_of edge", + }) + break + } + } + } + + // 3. Multi-parent project — any item with cardinality(parent_ids) > 1. + for _, it := range items { + if len(it.ParentIDs) > 1 { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: fmt.Sprintf("multi-parent project (%d parents) — verify all child_of edges land", len(it.ParentIDs)), + }) + break + } + } + + // 4. Project with a caldav-list link. + for _, it := range items { + if caldavItems[it.ID] { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "caldav-list-linked project — verify edges.metadata.url payload round-trip", + }) + break + } + } + + // 5. Project with public=true + public_description populated. + for _, it := range items { + if it.Public && it.PublicDescription != "" { + out = append(out, SpotCheck{ + ItemID: it.ID, Slug: it.Slug, Title: it.Title, + Reason: "public-listing project — verify metadata.projax.public.* bundle preserved for flexsiebels renderer", + }) + break + } + } + + // Stable order for deterministic output. + sort.SliceStable(out, func(i, j int) bool { return out[i].Slug < out[j].Slug }) + return out +} + +func containsString(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +}