Files
projax/docs/design.md
mAi dc50823860 feat(phase 3a mcp): MCP surface so mai/otto/Claude can read+write projax
mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools
delegate to *store.Store (no business-logic duplication).

- handler.go: handleRPC routes initialize / tools/list / tools/call /
  ping / notifications/initialized; Bearer-token middleware; results
  flow through the standard MCP content[].text envelope; tool errors
  surface as isError: true (transport errors stay JSON-RPC errors).
- tools.go: 10 tools — list_items / get_item / create_item /
  update_item / delete_item / list_links / add_link / remove_link /
  search / tree. Multi-parent in/out — parent_paths[] string array,
  resolved per call. itemView/linkView keep the wire shape snake_case
  and stable.
- mcp_test.go + tools_test.go: protocol primitives (no DB) plus a
  full create → get → search → delete round-trip skipping cleanly
  when the DB env is absent. Multi-parent assertion discovers the
  test pair from the live DB rather than hard-coding a row.

store extensions:
- ListByFilters(SearchFilters) with parent_path/tags/management/kind/
  status/q/has_repo/has_caldav predicates.
- Search(q, limit) ranked across title/slug/aliases/content_md.
- GetByPathOrSlug for callers that don't know the full path.
- SoftDeleteCascade refuses on live descendants unless cascade=true.

web:
- New optional Server.MCP http.Handler. main.go mounts an mcp.Server
  when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses
  the Supabase-cookie auth middleware (its own Bearer auth applies).
- Off cleanly when the token is unset.

ops:
- ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out,
  Bearer header).
- .mcp.json adds an http-transport entry for clients that speak
  HTTP+MCP natively.
- deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret.
- docs/design.md §7 added: tool list, multi-parent semantics, env
  contract, transport + bridge.
2026-05-15 17:59:03 +02:00

27 KiB
Raw Blame History

projax — PRD

Status: v1 draft, 2026-05-15 Authors: m, head (dialogue) Scope: Phase-1 build sufficient to live with the system; phases 23 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 '<path>' = 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)

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 .<slug> 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:

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:

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. Tag filter chips at the top toggle a ?tag=<csv> filter (an item is shown when it or any descendant carries every active tag). ×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.
  • MCPmcp__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=<absolute calendar URL>, 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 <base>/<item.slug>/ with display name <item.title>. 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:<UTC>), 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: <ETag> 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='<owner>/<repo>' (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 <rel>), 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 <GITEA_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 <PROJAX_MCP_TOKEN>. /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