# projax — PRD **Status:** v1 draft, 2026-05-15 **Authors:** m, head (dialogue) **Scope:** Phase-1 build sufficient to live with the system; phases 2–3 listed but deferred. ## 1. Purpose projax is m's personal data backbone for self-management — areas of life, projects within them, and aggregated views over tasks that live elsewhere. It subsumes (over time) the scattered state currently held in `mai.projects`, CalDAV task lists, Gitea issues, and mBrian topic hubs. No interface is canonical; each is a view. **Meta-requirement: flexibility.** m's self-model evolves. Identity is by UUID; everything human-readable is renameable. The data model leans on jsonb + array-typed kinds so future re-categorization doesn't require a migration. ## 2. Model ### 2.1 Items in a DAG Phase 1.5 collapsed the area/project structural distinction. Every node is an `item`; the `kind` array is kept for forward-compatibility (future: milestone, event, person, …) but `area` is no longer a special value. The seven seeded roots are just items with `parent_ids = '{}'`. The hierarchy is a **directed acyclic graph**, not a tree: each item has zero or more parents, and the same item can surface under multiple branches. `work.paliad` and `dev.paliad` resolve to the same row. PER citations can use any valid path. - **Item** — a node in the DAG. Examples: `dev`, `home.spring-clean`, `work.paliad`, `paliad.note`. The `kind` column carries `['project']` today; we may layer other types as needs arise. - **Task** — atomic work item. **Lives outside projax** (CalDAV todos, Gitea issues, `mai.tasks`). projax references and aggregates them; it does not own them. Structural rules: - No cycles (enforced by `items_before_write` + `compute_item_paths` recursive-CTE ancestor closure). - An item's slug must be unique among its siblings under any common parent (enforced by `items_check_slug_collision` BEFORE trigger, with the partial unique index `items_root_slug_uniq` covering the root case). - Soft delete via `deleted_at`. Hard delete cascades through `items_after_delete`, which scrubs the deleted id from every descendant's `parent_ids` array. ### 2.2 Identity & naming - `id uuid` — canonical, immutable. - `slug text` — local-only segment (no dots). Renameable freely. Examples: `prjx`, `spring-clean`. Multi-word leaves use kebab-case. - `parent_ids uuid[]` — zero or more parent ids. Root items have `parent_ids = '{}'`. - `paths text[]` — full dot-joined paths, one per ancestor lineage. Trigger-maintained from `parent_ids` + `slug`. Lookup via `'' = ANY(paths)`. - Slug convention: lowercase, vowel-elided where natural (`prjx`, `mai`, `mbrn`), kebab-allowed for multi-word leaves (`spring-clean`). - Aliases: `aliases text[]` keeps old slugs searchable after rename. - `tags text[]` — free-vocabulary cross-cutting labels (`work`, `dev`, `home`, `tech`, …). GIN-indexed. No fixed vocabulary. - `management text[]` — how the project is run: `self`, `mai`, `external`. An item can carry multiple modes. Empty array means "no specific management mode declared". ### 2.3 Lifecycle (thin) ``` active → done → archived ``` That's it. Free-text in `content_md` covers the nuance ("waiting on Brian," "paused until June"). No rich state machine; m flagged richer schemes as rot-prone. ### 2.4 Relationships - **Tree-as-DAG** (parent/child within projax): `items.parent_ids uuid[]`. Root items have `parent_ids = '{}'`. Any item may name multiple parents — the same row then appears under each branch with paths inherited from each lineage. - **External refs** (`projax.item_links`): each row links an `item_id` to a typed external resource — `caldav-todo`, `gitea-issue`, `github-repo`, `mai-task`, `mai-project`, `mbrian-node`, `url`, etc. Used both for aggregating tasks and for soft cross-references. ## 3. Schema (Postgres, msupabase, schema `projax`) ```sql create schema if not exists projax; create table projax.items ( id uuid primary key default gen_random_uuid(), kind text[] not null default '{}', -- ['area'] or ['project'] (multi-tag allowed for future) title text not null, slug text not null, -- local segment, no dots path text not null, -- computed, e.g. 'home.spring-clean' parent_id uuid references projax.items(id) on delete restrict, content_md text default '', aliases text[] not null default '{}', metadata jsonb not null default '{}'::jsonb, status text not null default 'active', -- active | done | archived pinned boolean not null default false, archived boolean not null default false, start_time timestamptz, end_time timestamptz, parent_ids uuid[] not null default '{}', paths text[] not null default '{}', tags text[] not null default '{}', management text[] not null default '{}', created_at timestamptz not null default now(), updated_at timestamptz not null default now(), deleted_at timestamptz ); create index items_paths_idx on projax.items using gin (paths); create index items_parent_ids_idx on projax.items using gin (parent_ids); create index items_kind_idx on projax.items using gin (kind); create index items_tags_idx on projax.items using gin (tags); create index items_management_idx on projax.items using gin (management); create unique index items_root_slug_uniq on projax.items (slug) where cardinality(parent_ids) = 0; create table projax.item_links ( id uuid primary key default gen_random_uuid(), item_id uuid not null references projax.items(id) on delete cascade, ref_type text not null, -- 'caldav-todo' | 'gitea-issue' | 'github-repo' | 'mai-task' | 'mai-project' | 'mbrian-node' | 'url' | ... ref_id text not null, -- opaque external identifier rel text not null default 'contains', -- 'contains' | 'related' | 'blocked-by' | 'derived-from' note text, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), unique (item_id, ref_type, ref_id, rel) ); create index item_links_item_idx on projax.item_links (item_id); create index item_links_ref_idx on projax.item_links (ref_type, ref_id); ``` ### 3.1 Path triggers (multi-parent) `paths` is a `text[]` maintained by `compute_item_paths(parent_ids, slug, self_id)`: - For root items (`parent_ids = '{}'`), `paths = [slug]`. - Otherwise, look up every parent's `paths`, append `.` to each, dedupe and sort. The recursion is implicit — parents' paths are kept up to date by the same trigger, so children just consume the precomputed prefixes. - Cycle detection: the function rejects when `self_id` appears anywhere in the recursive ancestor closure (`WITH RECURSIVE closure ...`). Plus a defensive direct-self-parent check. Two BEFORE triggers and two AFTER triggers cooperate: - `items_before_write` (BEFORE INSERT/UPDATE) — cycle guard + `new.paths := compute_item_paths(...)`. - `items_check_slug_collision` (BEFORE INSERT/UPDATE) — for each parent in `new.parent_ids`, refuse if another row already uses `new.slug` under that parent. - `items_after_reparent` (AFTER UPDATE of slug/parent_ids) — DFS over descendants via `refresh_item_paths_recursive`, parent-first ordering. A session GUC `projax.refreshing_paths` short-circuits the inner UPDATEs so the cascade fires exactly once. - `items_after_delete` (AFTER DELETE) — scrubs the deleted id from every other row's `parent_ids` array (we have no FK integrity on array elements; this is the manual cascade). ### 3.2 The `items_unified` view After Phase 1.5 mai.projects is a derived projection (see §3.4), so the view collapses to a thin projection over `projax.items`: ```sql create view projax.items_unified as select i.id, i.kind, i.title, i.slug, i.paths, i.parent_ids, i.content_md, i.aliases, i.metadata, i.status, i.pinned, i.archived, i.start_time, i.end_time, 'projax'::text as source, (select l.ref_id from projax.item_links l where l.item_id = i.id and l.ref_type = 'mai-project' limit 1) as source_ref_id, i.tags, i.management, i.created_at, i.updated_at from projax.items i where i.deleted_at is null; ``` `source` is always `'projax'` (kept for forward compat); `source_ref_id` surfaces the `mai-project` pointer when one exists so the UI can show "mai id: foo". ### 3.3 Classification overlay Items can land at root in two ways: - The backfill in migration 0007 heuristic-assigned every existing mai.projects row to one of the seven seeded areas (`dev`, `sports`, `work`, `home`, `health`, `finances`, `social`). None ended up at root in this pass. - The reverse sync trigger (§3.4) drops every NEW mai.projects row at root with `management = ['mai']`, leaving m to classify it via `/admin/classify`. `/admin/classify` surfaces items where `cardinality(parent_ids) = 0 AND 'mai' = ANY(management)`. The inline form posts to `/i/{path}/reparent` to move the item under a chosen parent without touching its other fields. ### 3.4 mai.projects bidirectional sync (Phase 1.5) `mai.workers`, `mai.tasks`, `mai.sessions`, `mai.messages`, `mai.metrics` and `mai.pwa_head_pins` hold FKs into `mai.projects(id)`, so the table cannot be replaced by a view. Instead it becomes a **derived projection** kept in sync by triggers: - **Forward** (`projax.sync_to_mai`, AFTER INSERT/UPDATE/DELETE on `projax.items`) — when an item has `'mai' = ANY(management)`, upsert/update/delete the matching `mai.projects` row. Slug stays the join key (`mai.projects.id = projax.items.slug` at creation), but FK targets cannot be renamed, so projax slug and mai id may drift after a rename; the cross-system pointer in `item_links(ref_type='mai-project')` remains stable. - **Reverse** (`projax.sync_from_mai`, AFTER INSERT/UPDATE/DELETE on `mai.projects`, `SECURITY DEFINER` so `mai` role writes can fan out into `projax.items` which projax_admin owns) — mirror the change into a `projax.items` row, dropping new rows at root with `management = ['mai']` so `/admin/classify` can pick them up. - **Cycle prevention** — both functions short-circuit when `pg_trigger_depth() > 1` (the natural recursion case) and additionally honour a `projax.in_sync` session GUC as belt-and-braces. The mai.projects → projax.items reverse trigger requires manual prereqs on msupabase: ```sql GRANT INSERT, UPDATE, DELETE, TRIGGER ON mai.projects TO projax_admin; DROP POLICY IF EXISTS projax_write ON mai.projects; CREATE POLICY projax_write ON mai.projects FOR ALL TO projax_admin USING (true) WITH CHECK (true); ``` (documented in 0008's header and the README). ## 4. Interfaces ### 4.1 Phase 1 + 1.5 — Web frontend (this build) **Web frontend at `https://projax.msbls.de`**, single binary, served by the same Go process that talks to msupabase. Pages: 1. **Tree view** (`/`) — DAG rendering: every item appears under each of its parents. Status / management / tag chips per row. Filter bar at the top combines a debounced search input with chip rows for tags, management, status, and has-link (caldav / gitea), plus a "show archived" toggle. Filters compose: AND across dimensions, OR within (except tags, which AND). Each chip shows the count it would yield if toggled (so `work (12)` means flipping that chip on lands you at 12 matching items). Status defaults to `active` only; archived rows hide until either the `archived` status chip is selected AND the show-archived toggle is on. The URL is the source of truth — every filter is in the query string (`?q=…&tag=…&mgmt=…&status=…&has=…&show-archived=1`), so any view is bookmarkable. HTMX swaps the tree-section in place on every chip click and on `keyup` of the search input (200ms debounce); `hx-push-url` keeps the browser URL in sync. `×N` badge on multi-parent items shows how many paths they live at. 2. **Item detail** (`/i/{path}`) — `{path}` matches any entry in `paths`; both `work.paliad` and `dev.paliad` resolve to the same row. The page shows the primary path plus an "Also at: …" breadcrumb for the others. Edit form supports title, slug, multi-select parents, status, tags, management, pinned/archived, content. Save POSTs to `/i/{path}`. 3. **New item** (`/new?parent={path}`) — same form shape; the `parent` query pre-selects one parent option, m can pick more. 4. **Classify** (`/admin/classify`) — surfaces items at root with `'mai' = ANY(management)`. Inline HTMX form sets the first parent. POSTs to `/i/{path}/reparent`. 5. **Auth** — projax's own `/login` (mBrian pattern). Same Supabase backend, per-host cookies (no `Domain` attribute). ### 4.2 Tags + management - **Tags** (`projax.items.tags text[]`, GIN-indexed) — free vocabulary, no fixed list. Cross-cutting labels for "work-y dev things" (`['work', 'dev']`), "health priorities" (`['health']`), etc. Filter chips at `/` reveal which tag-flavoured slices exist. - **Management** (`projax.items.management text[]`, GIN-indexed) — declarative mode flags: - `mai` — bidirectional sync with `mai.projects`. Adding/removing `mai` toggles the mai.projects mirror on/off (with FK safety: removal fails if workers/tasks still reference the project). - `self` — m runs this manually; otto does not orchestrate. - `external` — owned by a third party; projax mirrors metadata only. Mai.projects backfilled rows arrive with `management = ['mai']`. m can layer `self` on top without dropping mai sync. ### 4.2 Phase 2 — task aggregation - **CalDAV ingest** — read-only mirror of m's CalDAV todo lists into `item_links` with `ref_type=caldav-todo`. Per-area mapping (e.g. `home` aggregates from CalDAV list "Home"). Background sync, no writeback initially. - **Gitea ingest** — read-only mirror of issues on linked repos. `mai.projects.repo` field is a hint; per-item override possible. ### 4.3 Phase 3 — visualization & integrations - **Excalidraw view** — visual roadmap, dependencies, area-overview boards. Generated from items_unified. - **MCP** — `mcp__projax__*` so otto and other workers can read/write projax. Pattern follows mcp__mai__. - **Otto-PWA integration** — read-mostly surface for m's day-to-day. Defer until projax has lived long enough to know what otto actually needs. ## 5. Tech stack - **Backend**: Go single binary. `pgx` for Postgres. HTMX-driven HTML rendered server-side (Go `html/template`). No frontend build step. Static assets bundled with `embed`. Matches m's dotfile-stated preferences. - **Database**: msupabase, schema `projax` (new). View `projax.items_unified` reads across `projax.*` + `mai.projects`. RLS off for v1 (single-user). - **Hosting**: Dokploy on mlake, domain `projax.msbls.de`. Tailscale-only network (no public exposure). - **Repo**: `m/projax` (already exists). Branch strategy per project CLAUDE.md (main + short-lived feat/fix branches, no dev branch initially). Alternative considered: SvelteKit + Bun (matches flexsiebels). Rejected for v1 — CRUD admin scale doesn't justify the build chain. ## 6. Migration plan Three phases, smallest viable each: **1a — Schema + seed**: create `projax.items`, `projax.item_links`, path trigger. Seed the seven day-one areas (`dev`, `sports`, `home`, `work`, `health`, `finances`, `social`) as `kind=['area']`, `parent_id=null`. **1b — Adapter view**: deploy `items_unified`. All 28 mai.projects rows now visible in the tree as top-level orphans. **1c — Classification UI**: the `/admin/classify` page so m can drag mai.projects rows under areas. Drag = create a projax-native item with `kind=['project']` + `parent_id` set + `item_links` row pointing at the mai.projects row. mai.projects untouched; the projax row owns area assignment + projax metadata. After 1c, m can use the system. Test rows in mai.projects either stay as orphans (ignored) or get a `source-filter` to hide them. ## 7. Out of scope - Multi-user (single-tenant, m only) - Mobile-first responsive (desktop browser is enough) - Public exposure (Tailscale only) - Generic SaaS instincts (admin panels, billing, audit logs) - CLI surface (m has explicitly opted out) - Bidirectional Gitea sync in v1 (read-only mirror first; CalDAV is full read/write as of phase 2.b) - Real-time collaboration features ## 5. CalDAV integration (Phase 2, v1: full read/write) m's CalDAV server lives at `dav.msbls.de/dav/calendars/m/` (SabreDAV, Basic auth via `DAV_USER`/`DAV_PASSWORD`). projax v1 wires the slice m exercises day-to-day: - **Link model**: a `projax.item_links` row with `ref_type='caldav-list'`, `ref_id=`, `metadata={display_name, calendar_color, linked_at, …}`. Same item_links row pattern as `mai-project` / `gitea-repo`. An item can be linked to multiple calendars; a calendar can be linked to multiple items (rare in practice). - **Discovery** (`GET /admin/caldav`): the binary PROPFINDs Depth: 1 against the base URL, filters out non-calendar collections (`inbox`/`outbox`), and pairs each discovered calendar with the projax item whose lowercased title or slug matches the calendar's display name. m confirms or overrides each suggestion. - **Linking** (`POST /admin/caldav/link` / `/admin/caldav/unlink`): single-row CRUD on item_links. No background sync. - **Task aggregation** (item detail page): for each linked calendar, the binary REPORTs `calendar-query` for VTODOs and renders open + recent-completed tasks. Each row carries its server ETag and raw ICS so the writeback affordances below can do optimistic-concurrency PUTs. Errors per-calendar are logged and skipped — one bad list does not blank the section. - **Create-on-demand list** (`POST /i/{path}/caldav/create`): MKCALENDAR at `//` with display name ``. If the URL is already in use (SabreDAV returns 405), the binary links to the existing calendar instead. - **Writeback affordances on the detail page (phase 2.b)**: each VTODO row exposes complete (checkbox → `STATUS:COMPLETED` + `COMPLETED:`), reopen (`STATUS:NEEDS-ACTION`, COMPLETED cleared), inline edit of SUMMARY + DUE, and hard-delete via `×` with an `hx-confirm` dialog. An "Add task" form at the top of each linked calendar POSTs a fresh VTODO (UID is a server-generated RFC 4122 v4). All five actions are HTMX-driven (`hx-post` + `hx-target="#tasks-section"` + `hx-swap="outerHTML"`): the handler re-renders the tasks fragment so the swap reflects the post-write server state. - **Optimistic concurrency**: every edit/complete/reopen/delete request carries an `If-Match: ` header. The handler first re-`ListTodos`'es the calendar (small calendars → cheap; ETags from the page render may have drifted) and uses the live ETag, so ordinary use never trips 412. When the server still returns 412 — e.g. another client edited between refetch and PUT — the section re-renders with a banner: "Task changed elsewhere since this page was loaded — refresh and retry." The cached ETag table envisioned in Phase 2.c remains parked until live REPORT-querying gets slow. - **ICS round-trip**: writes that modify an existing task call `ApplyVTodoEdit` against the server's raw ICS so unknown properties (DESCRIPTION, CATEGORIES, X-extensions, …) survive the round-trip. Only the keys projax knows about (SUMMARY, STATUS, COMPLETED, DUE, PRIORITY, LAST-MODIFIED, DTSTAMP) get rewritten. New tasks go through `BuildVTodoICS` which emits a minimal but valid VCALENDAR wrapper with RFC 5545 folding at 75 octets and CRLF terminators. - **Multi-parent items** keep ONE list per item — the URL is derived from the slug, not the path. `paliad` gets `/dav/calendars/m/paliad/` whether it lives at `work.paliad`, `dev.paliad`, or both. - **Authorisation**: writeback handlers reject calendar URLs not currently linked to the item, so a crafted form can't route writes to arbitrary collections. - **Out of scope (still parked)**: RRULE / recurring VTODOs (rendered as single occurrences until m needs more), background sync, multi-calendar drag-and-drop. Phase 2.c may add a TTL'd `cached_tasks` table if live REPORT-querying gets slow at m's scale. Env contract: `DAV_URL` (default `https://dav.msbls.de/dav/calendars/m/`), `DAV_USER`, `DAV_PASSWORD`. All three live in Dokploy secrets; missing → `/admin/caldav` renders a "not configured" notice and the detail page hides the Tasks section. ## 6. Gitea integration (Phase 2.d, v1: read-only) m's Gitea instance lives at `mgit.msbls.de` (token auth, automation account `mAi`). projax v1 reads but does not write: - **Link model**: a `projax.item_links` row with `ref_type='gitea-repo'`, `ref_id='/'` (e.g. `m/projax`, `mAi/paliad`, `HL/mWorkRepo`). The Phase 1.5 backfill already populated this row for every `mai.projects` with a `repo` field. An item can carry multiple `gitea-repo` links — projax sums them on the detail page. - **Issues section** (item detail page, rendered when at least one `gitea-repo` link exists): per-repo block with open issues (`#N · title · labels · milestone · assignees · updated `), a `↗ Gitea repo` link in the header, and a disclosure for the last-30-days closed issues (up to 20). Title and number link out to `htmlURL` on Gitea (`target="_blank"`). Failed fetches (404, network) surface as a per-repo banner so one missing repo doesn't blank the section. - **Listing**: `GET /api/v1/repos/{owner}/{repo}/issues?state=open&type=issues&limit=50` for the open list; same shape with `state=closed&since=<-30d>&limit=20` for the recent-closed disclosure. `type=issues` filters PRs out server-side on Gitea ≥1.20; the client also drops any `pull_request != null` rawIssue as belt-and-braces. - **Caching**: per-process, in-memory TTL cache (~3 min) keyed by `owner/repo|state` so rendering the same detail page back-to-back does not hammer Gitea. No DB cache table at v1; a `projax.cached_issues` would land in 2.f if perf bites. - **Auth**: `Authorization: token `. The token is the **mAi** automation account (`GITEA_TOKEN_AI` in `.env.age`) — keeps projax's reads attributed to mAi for audit purposes, same as how every other automated worker talks to Gitea. Missing token + non-empty URL → fail-fast at boot. - **PR aggregation, issue writeback, webhook live updates**: parked. Writeback is Phase 2.e if m wants it; webhook-driven freshness is 2.f. Env contract: `GITEA_URL` (e.g. `https://mgit.msbls.de`, no `/api/v1` suffix), `GITEA_TOKEN`. Both live in Dokploy secrets; `GITEA_URL` unset → integration off cleanly (Issues section just doesn't render). `GITEA_URL` set but `GITEA_TOKEN` missing → refuse to start. ## 7. MCP surface (Phase 3a) projax exposes its data + writes through an MCP server mounted on the same binary at `/mcp/rpc`. Mirrors the conventions of `mcp__mai__*` and `mcp__mai-memory__*` — one tool per coherent operation, snake_case names, structured JSON results carried inside the standard MCP `content[].text` envelope. ### Tools | name | summary | key inputs | |-------------------|---------|------------| | `list_items` | List items with filters | `parent_path`, `tags[]`, `management[]`, `kind[]`, `status`, `q`, `has_repo`, `has_caldav`, `limit` | | `get_item` | Fetch one item by id or path | `id` xor `path`, `include_links` (default true) | | `create_item` | Create a new item | `slug`, `title`, `parent_paths[]`, `kind[]`, `tags[]`, `management[]`, `content_md`, `status`, `metadata` | | `update_item` | Partial update of an existing item | `id` xor `path`, any subset of editable fields | | `delete_item` | Soft-delete; refuses on live descendants unless `cascade=true` | `id` xor `path`, `cascade` | | `list_links` | List item_links attached to an item | `id` xor `path`, optional `ref_type` | | `add_link` | Add an external item_link | `ref_type`, `ref_id`, `rel`, `note`, `metadata` | | `remove_link` | Delete an item_link by id | `link_id` | | `search` | Ranked substring search across title/slug/aliases/content_md | `query`, `limit` | | `tree` | Nested tree (multi-parent items appear under each branch) | `root_path`, `depth` | ### Output shape All tools return a JSON object inside a single MCP text-content block. `list_items`, `list_links`, `search`, `tree` return `{count|roots, items|links|tree}`. `get_item` and write tools return a single `itemView` / `linkView` with snake_case fields matching `projax.items_unified`'s columns. ### Multi-parent semantics - `list_items` with `parent_path='work'` matches any item whose `paths[]` contains a path equal to `work` or beginning with `work.` — multi-parent items surface from any ancestor. - `get_item` resolves either by uuid or by any path the row publishes; `dev.paliad` and `work.paliad` return the same row. - `create_item` accepts `parent_paths` as a string array: `[]` for a root, `['work']` for single-parent, `['work', 'dev']` for multi. - `update_item` with a non-nil `parent_paths` *replaces* the full parent list; pass the current list plus the new one to add a parent. - `tree` honours multi-parent — the same uuid appears under each branch with its inherited path as the node's `path` field. ### Transport + auth - HTTP+JSON-RPC 2.0 over `POST /mcp/rpc` (no SSE needed at v1 — every tool returns synchronously). - Bearer auth via `Authorization: Bearer `. `/mcp/*` paths are exempt from the cookie auth middleware so API callers don't need a Supabase session. - A GET on `/mcp/rpc` returns a small descriptor `{server, version, protocolVersion, tools[], authRequired}` for ops smoke-testing. ### Bridge for stdio MCP clients `~/.claude/mcp/projax.sh` is a tiny bash bridge: reads NDJSON JSON-RPC frames from stdin, POSTs each to `${PROJAX_MCP_URL}/rpc` with the Bearer header, writes the response back to stdout. The repo-root `.mcp.json` exposes both wirings: - An `http` server entry for clients that speak HTTP+MCP natively. - A `command` server entry (referenced separately under `~/.claude/mcp/projax.sh`) for stdio-only clients. Neither encodes a token; both interpolate `${PROJAX_MCP_TOKEN}` at session start. ### Env contract - `PROJAX_MCP_TOKEN` — 32-char Bearer secret. Unset → `/mcp/*` returns 404 (off cleanly, the web UI keeps working). Set → routes mount, every request requires the matching Bearer. Out of scope (parked): - Server-pushed notifications / SSE — phase 3b. - Bulk import/export tools — phase 3b. - Otto-PWA integration that consumes this surface — separate worker. ## 8. Open questions (post-PRD) - **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger. - **`mai.projects` test row hiding**: drop them from the view via name pattern, or surface them with a "test" tag? - **Classification promotion semantics**: when a mai.projects row is promoted, does the projax item replace it in the unified view, or do both still appear? Default: projax wins, view filters out adapted mai rows. - **Auth**: re-use flexsiebels Supabase auth, or simpler shared-secret cookie? msupabase auth is heavier than v1 needs. - **mBrian topic-hub linkage**: do we auto-suggest mbrian topic links when an item is created with a matching slug? Defer to phase 3. ## 9. Phase-1 deliverable checklist - [ ] `projax.items` + `projax.item_links` migrations in `db/migrations/` - [ ] Path trigger + tests - [ ] `projax.items_unified` view - [ ] Go binary: HTTP server, pgx pool, html/template + HTMX, embed static - [ ] Pages: tree, detail, new, classify - [ ] Auth: msupabase session cookie OR shared-secret (decide in 1a) - [ ] Dockerfile + Dokploy config for `projax.msbls.de` - [ ] Seed migration for the seven day-one areas - [ ] README + run instructions ## 10. References - Project CLAUDE.md (this repo) — purpose, constraints, gated worker flow - `~/.claude/CLAUDE.md` — global conventions (memory, channel routing, git strategy) - `mai.projects` schema (msupabase) — current state being adapted - mBrian `nodes`/`edges` schema — terminology source - otto session 2026-05-15 — inventory motivating this project