# 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". **Soft-delete tightening (migration 0013, Phase 3d).** Every `item_links` row is implicitly tied to its parent item's life: on soft-delete (`projax.items.deleted_at` flips `NULL → not-null`) a BEFORE-UPDATE trigger cascades a `DELETE FROM projax.item_links WHERE item_id = NEW.id` in the same statement. The migration also one-shot-cleans the ~12 orphan `mai-project` rows that predate this trigger. Result: `count(item_links WHERE ref_type=X)` and `count(items_unified WHERE source_ref_id IS NOT NULL)` stay in lock-step — `TestItemsUnifiedSurfacesMaiPointer` regression-guards this. ### 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. **Bulk edit** (`/admin/bulk`, Phase 3d) — desktop-only multi-row editor. Top: a filter form that reuses the same query params as the tree page (`q`, `tag`, `mgmt`, `status`, `has`, `show-archived`) so URLs translate 1:1 between tree and bulk views. Below: a flat checkbox list of every matching row (slug, primary path, tags, mgmt, status). An action bar at the top supports four operations: add tag, remove tag, set management (mai/self/external/clear), set status (active/done/archived). One POST to `/admin/bulk/apply` runs every change inside a single transaction (rollback-on-error). Inline per-row chip edits use `POST /admin/bulk/chip` for one-off add/remove without ticking a checkbox; only the affected cell re-renders. 6. **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. **Area-tag backfill (migration 0012, Phase 3d).** Backfilled mai-managed items landed with `tags = '{}'`, so the tree-page tag filter chips had no signal to filter on. Migration 0012 one-shot-populates `tags` with the slug of each area an item lives under (so an item under `work.flexsiebels` picks up `tag=work`; a multi-parent item under `work.paliad` AND `dev.paliad` picks up `['dev', 'work']`). The migration only touches rows where `tags = '{}'`; once m has edited an item's tags it is left alone. Going-forward bulk recovery uses `/admin/bulk` instead of repeating the migration. ### 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 read; 3h writeback) m's Gitea instance lives at `mgit.msbls.de` (token auth, automation account `mAi`). Phase 2.d landed read-only; Phase 3h extended it to read + write for the four most common operations: - **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. - **Writeback (Phase 3h)** — four operations on the Issues section + dashboard Issues card: - **Close** an open issue (`PATCH /repos/{o}/{r}/issues/{n}` with `{"state":"closed"}`) — single click, no confirm modal (cheap to reopen). - **Reopen** a closed issue (same endpoint with `{"state":"open"}`). - **Comment** on an issue (`POST /repos/{o}/{r}/issues/{n}/comments` with `{"body":...}`). - **Create** a new issue under a linked repo (`POST /repos/{o}/{r}/issues` with `{"title":..., "body":...}`). - **Authorisation**: writeback handlers reject any `repo` form value that isn't linked to the item via a `gitea-repo` item_link. Prevents form-crafted writes against arbitrary repos. - **Token permission**: the mAi token (`GITEA_TOKEN_AI`) needs write scope on m's repos. A 401/403 surfaces as `gitea.ErrForbidden` and renders an inline "Gitea token lacks write access" banner so the page never breaks. - **Cache busting**: every successful writeback invalidates both the Gitea per-repo cache entries (`{repo}|open` + `{repo}|closed-recent`) and the dashboard 60s TTL (all keys) so the next render reflects the upstream change. - **Parked further**: PR creation, label edit (folded in only if cheap), issue title/body edit, comment edit/delete, webhook live updates, cross-repo bulk ops, issue templates. 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. ## Documents / dated artifacts (Phase 3c) The PER standard (`docs/standards/per.md`) needs a `(item, event_date)` pair as its backing store. Phase 3c lands it. - **Schema**: migration `0011_item_links_event_date.sql` adds `projax.item_links.event_date date` (nullable) and a partial index. Day granularity per the PER spec; time-of-day is intentionally out of scope. - **Ref-type convention**: existing types (`caldav-list`, `gitea-repo`, `gitea-issue`, `mai-project`, …) keep their meaning. Phase 3c adds three convention names for dated artifacts: - `document` — generic external pointer (URL, local file path, Drive link, …) - `note` — short text snippet; the body lives in `note` or `metadata.body` - `url` — bookmarked link (rendered as a clickable anchor) The schema doesn't enforce these — the column is `text` — but the UI uses them to render differently. - **Detail page → Documents section** (renders unconditionally on every `/i/{path}` page; empty-state copy when no dated links exist): - Lists every `item_link` with `event_date IS NOT NULL` for the item, ordered `event_date DESC, created_at ASC`. - Each row shows the computed PER (`.[.]`), a ref-type badge, the ref_id (clickable for `url`), the optional note, and a small `×` to remove. - Add form (top of section): `ref_type | event_date | ref_id | note`. POSTs to `/i/{path}/links/add` → HTMX swap. - Collision tags (`.a`, `.b`, …) are **computed at render time only**, never stored. The first link on a date is bare; the second gets `.a`, the third `.b`. Order is by `created_at` within the same date. - **URL resolution** for PER-cited paths: `handleDetail` first tries the literal `path`; if it 404s and the trailing segment looks like `YYMMDD`, it retries with the date stripped and surfaces the parsed date as a render hint so the Documents section can scroll to / highlight the matching row. Invalid dates (Feb 30, 99/99/99) are not stripped — they hit the original 404 path. - **MCP**: `add_link` accepts an optional `event_date: "YYYY-MM-DD"`. Existing callers without it keep working. `linkView.event_date` surfaces the stored value on the response side. The conflict policy on duplicate `(item_id, ref_type, ref_id, rel)` is `COALESCE(new, old)` for note/event_date so partial updates don't clobber an earlier date by accident. - **Anti-forgery on remove**: the `/links/remove` handler verifies the link's `item_id` matches the URL's item before deleting — a crafted form can't snipe a link that belongs to a different item. Out of scope (parked): - File uploads / in-projax storage. v1 references only. - Recurring dated artifacts (RRULE-style). Flatten for now. - Cross-PER linking syntax / forward-jump anchors. Phase 3d+ if m needs it. ## 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. ## Dashboard / daily-driver view (Phase 3e) A single landing surface at `/dashboard` that aggregates open work and recent activity across every linked project, so projax can be opened first thing in the morning instead of clicking through each project's detail page. **Sections** (each a card, count in header): 1. **Open tasks** — every open VTODO (`Status != COMPLETED/CANCELLED`) from every `caldav-list` item_link, fanned out via a 4-worker pool to avoid DAV-server hammering. Bucketed by due date: `Overdue` (red), `Today`, `Tomorrow`, `This week` (≤7d), `No due`. Sort: overdue first, then due asc with no-due last; ties by priority desc, summary asc. Capped at 30 rows; total + per-bucket counts surface in the section header. Each row has a ✓ button that POSTs `/dashboard/task/done` with `calendar_url + uid`, flips the VTODO to `COMPLETED` via the existing PUT path, busts the dashboard cache, and re-renders the full section (so the row vanishes and counts decrement). 2. **Open issues** — every open Gitea issue from every `gitea-repo` item_link, sorted `updated_at desc`. Read-only (Gitea writeback parked). Reuses the existing `GiteaDeps.Cache` (3-min TTL) so repeated dashboard loads share Gitea hits with the detail page. 3. **Recent documents** — every dated `item_link` (`event_date IS NOT NULL`) with `event_date >= now - 30d`, joined to its parent item. Sorted newest-first. Each row renders the canonical PER (`{primary_path}.{YYMMDD}`), ref_type badge, note, ref_id link, and project path. Capped at 30. **Filters**: small chip row at the top reuses `tree_filter.go` URL params (`tag`, `mgmt`, `has`) so `/dashboard?tag=work` scopes all three cards to work-tagged items. The same filter has another use as the cache key — `/dashboard` and `/dashboard?tag=work` are independent cache entries. **TTL cache**: 60s in-memory map keyed by the encoded TreeFilter. The cache is single-replica only (no shared state needed at single-user scale). The ✓-mark-done handler explicitly invalidates the cache so the row disappears immediately on the next render. **Out of scope for 3e**: real-time updates, full per-section pagination, dashboard-as-root-landing. Tree at `/` stays the default surface; nav bar adds a "dashboard" link so m chooses when to switch. **Phase 3g additions:** 4. **Stale projects** — items with `'mai' = ANY(management)` AND every linked Gitea repo's `updated_at` older than 60d AND zero open VTODOs across linked CalDAV lists AND zero open Gitea issues. Sorted longest-stale first, capped at 20. Each row shows the project path, the quiet repo, and "last active Nd ago" with the absolute date on hover. "Consider archiving?" framing only — no auto-action. - Uses the same 4-worker pool as the issues card. Per-item task/issue counts are reused from the already-aggregated Tasks/Issues cards (no second DAV/Gitea pass). - Items with NO linked repo are skipped — without a signal there is no way to call them stale. - When an item has multiple linked repos, ALL must be older than the cutoff (so an item with one quiet repo and one busy repo is NOT stale). 5. **Last-refresh indicator** — small "updated Nm ago · cached" / "updated Nm ago · fresh" line at the top of the dashboard chrome, derived from the cached payload's BuiltAt timestamp. 6. **Force-refresh button** — `↻ refresh` link that adds `?refresh=1` to the current URL. The handler invalidates the matching cache key and re-runs the full aggregation. HTMX swaps the section in-place. 7. **Empty-card collapse** — when no filter is active AND a card has zero rows, render a one-line `No open tasks.` / `No open issues.` / `No recent documents.` note instead of the full empty-state block. With a filter active the card chrome stays so m can distinguish "filter hid the data" from "no data". ## Graph view (Phase 3f) A read-only top-down DAG render of every projax item at `/graph`, server-rendered inline SVG — no client-side layout library, no Excalidraw file. Trade-offs: m gets a single page that prints, downloads, and reflows in a regular browser; no drag-to-rearrange (read-only is enough for the daily glance). **Layout** (in `internal/graph`): - `LayerByLongestPath(nodes)` → each node's layer is `max(layer(parent)) + 1`, so a multi-parent item like `paliad` (under both `work` and `dev`) sits below whichever lineage is longer. Roots are layer 0. Depth-capped at 64 to bail loudly on cycles (the schema trigger already forbids cycles on write). - `OrderInLayer(layers)` — alphabetical by slug inside each layer for deterministic rendering. No barycenter / crossing-minimisation pass — at m's scale (≤ a few hundred items) the readability cost is negligible. - `Compute(nodes, opts)` returns positions + edges + canvas size. Pure-Go, no external deps. Unit-tested with multi-parent, longest-path-wins, sort, and cycle-guard fixtures. **Node styling**: - 130×44 px box per item. - Border colour = management mode: `mai` blue, `self` green, `external` orange, mixed dashed purple, unmanaged grey. - Box opacity = status: active 1.0, done 0.6, archived 0.3. - Slug as the main label; first three tags rendered as small pills along the bottom (`+N` overflow); `×N` badge top-right for multi-parent items. - `` element gives a hover tooltip with title + status + management. - Each node wrapped in an `<a href="/i/{path}">` so a click navigates to the detail page. **Filter chips**: same `tree_filter.go` URL params (`q`, `tag`, `mgmt`, `status`, `has`). Default behaviour is to *dim* non-matching nodes (opacity 0.15) so the structural relationships stay visible. `?isolate=1` switches to hide-non-matching mode and drops every edge whose endpoint is hidden. **Print + download**: SVG is inline so the browser's "Print" produces a real vector page. `?download=svg` serves the raw SVG with `Content-Disposition: attachment; filename="projax-graph.svg"` — useful for stashing a snapshot in slides or in mBrian. **Out of scope for 3f**: editable layout (drag-to-rearrange), Excalidraw file export, auto-refresh on item changes. ## 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