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) +} diff --git a/docs/plans/mbrian-backend-migration.md b/docs/plans/mbrian-backend-migration.md new file mode 100644 index 0000000..637a6b9 --- /dev/null +++ b/docs/plans/mbrian-backend-migration.md @@ -0,0 +1,434 @@ +# mBrian-as-backend migration — Phase 6 design + +**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 + +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 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 | +| `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 (settled) + +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. + +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 + +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 (re-baselined against live schema) + +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. + +### Already satisfied (no DDL needed) + +| 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. | + +### Remaining mBrian-side artifact + +**MB-B — projax-integration `[schema]` convention node.** One new mBrian node, no DDL. Lives under a new `[topic]` hub `projax-integration`. Documents: + +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). + +mBrian-side coder writes this node by creating it via mBrian's editor or MCP. No migration file needed. + +### mBrian owns the data-migration script + +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). + +--- + +## §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 (mBrian-owned) + +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. + +### projax-side input snapshot + +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). + +### mBrian-side script outline (for the m/mBrian issue body) + +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 (re-baselined) + +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. + +- **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. + +- **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. + +- **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. + +- **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). + +- **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. + +- **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: + +``` +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) +``` + +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 (settled) + +Per m's Q4=(a) + his words *"mbrian must own the migration"*: + +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. **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**: 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`. 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 (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. + +**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, 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.