- gitea pkg: CloseIssue, ReopenIssue, CreateIssue, AddComment + ErrForbidden
classification on 401/403. Client.do sets Content-Type on non-empty bodies.
- web handler: POST /i/{path}/issues/{close|reopen|comment|create}
- authorisation guard: repo form value must match a gitea-repo item_link
on the target item (rejects form-crafted writes to unrelated repos)
- HTMX re-renders issues_section partial after each action
- busts gitea per-repo cache (open + closed-recent) and dashboard 60s TTL
- templates: ✓ close button + reopen + collapsible comment box on every
issue row; "+ new issue" disclosure per repo
- design.md §6 retitled "Phase 2.d read; 3h writeback" with auth/perm
semantics + parked list
- 5 unit tests in gitea/, 5 integration tests in web/ covering happy paths
+ 403 → inline banner fallback
39 KiB
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. Thekindcolumn 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_pathsrecursive-CTE ancestor closure). - An item's slug must be unique among its siblings under any common parent (enforced by
items_check_slug_collisionBEFORE trigger, with the partial unique indexitems_root_slug_uniqcovering the root case). - Soft delete via
deleted_at. Hard delete cascades throughitems_after_delete, which scrubs the deleted id from every descendant'sparent_idsarray.
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 haveparent_ids = '{}'.paths text[]— full dot-joined paths, one per ancestor lineage. Trigger-maintained fromparent_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 haveparent_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 anitem_idto 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_idappears 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 innew.parent_ids, refuse if another row already usesnew.slugunder that parent.items_after_reparent(AFTER UPDATE of slug/parent_ids) — DFS over descendants viarefresh_item_paths_recursive, parent-first ordering. A session GUCprojax.refreshing_pathsshort-circuits the inner UPDATEs so the cascade fires exactly once.items_after_delete(AFTER DELETE) — scrubs the deleted id from every other row'sparent_idsarray (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".
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 onprojax.items) — when an item has'mai' = ANY(management), upsert/update/delete the matchingmai.projectsrow. Slug stays the join key (mai.projects.id = projax.items.slugat creation), but FK targets cannot be renamed, so projax slug and mai id may drift after a rename; the cross-system pointer initem_links(ref_type='mai-project')remains stable. - Reverse (
projax.sync_from_mai, AFTER INSERT/UPDATE/DELETE onmai.projects,SECURITY DEFINERsomairole writes can fan out intoprojax.itemswhich projax_admin owns) — mirror the change into aprojax.itemsrow, dropping new rows at root withmanagement = ['mai']so/admin/classifycan pick them up. - Cycle prevention — both functions short-circuit when
pg_trigger_depth() > 1(the natural recursion case) and additionally honour aprojax.in_syncsession 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:
- 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 (sowork (12)means flipping that chip on lands you at 12 matching items). Status defaults toactiveonly; archived rows hide until either thearchivedstatus 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 onkeyupof the search input (200ms debounce);hx-push-urlkeeps the browser URL in sync.×Nbadge on multi-parent items shows how many paths they live at. - Item detail (
/i/{path}) —{path}matches any entry inpaths; bothwork.paliadanddev.paliadresolve 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}. - New item (
/new?parent={path}) — same form shape; theparentquery pre-selects one parent option, m can pick more. - Classify (
/admin/classify) — surfaces items at root with'mai' = ANY(management). Inline HTMX form sets the first parent. POSTs to/i/{path}/reparent. - 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/applyruns every change inside a single transaction (rollback-on-error). Inline per-row chip edits usePOST /admin/bulk/chipfor one-off add/remove without ticking a checkbox; only the affected cell re-renders. - Auth — projax's own
/login(mBrian pattern). Same Supabase backend, per-host cookies (noDomainattribute).
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 withmai.projects. Adding/removingmaitoggles 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_linkswithref_type=caldav-todo. Per-area mapping (e.g.homeaggregates from CalDAV list "Home"). Background sync, no writeback initially. - Gitea ingest — read-only mirror of issues on linked repos.
mai.projects.repofield 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.
pgxfor Postgres. HTMX-driven HTML rendered server-side (Gohtml/template). No frontend build step. Static assets bundled withembed. Matches m's dotfile-stated preferences. - Database: msupabase, schema
projax(new). Viewprojax.items_unifiedreads acrossprojax.*+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_linksrow withref_type='caldav-list',ref_id=<absolute calendar URL>,metadata={display_name, calendar_color, linked_at, …}. Same item_links row pattern asmai-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-queryfor 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 anhx-confirmdialog. 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
ApplyVTodoEditagainst 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 throughBuildVTodoICSwhich 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.
paliadgets/dav/calendars/m/paliad/whether it lives atwork.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_taskstable 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_linksrow withref_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 everymai.projectswith arepofield. An item can carry multiplegitea-repolinks — projax sums them on the detail page. - Issues section (item detail page, rendered when at least one
gitea-repolink exists): per-repo block with open issues (#N · title · labels · milestone · assignees · updated <rel>), a↗ Gitea repolink in the header, and a disclosure for the last-30-days closed issues (up to 20). Title and number link out tohtmlURLon 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=50for the open list; same shape withstate=closed&since=<-30d>&limit=20for the recent-closed disclosure.type=issuesfilters PRs out server-side on Gitea ≥1.20; the client also drops anypull_request != nullrawIssue as belt-and-braces. - Caching: per-process, in-memory TTL cache (~3 min) keyed by
owner/repo|stateso rendering the same detail page back-to-back does not hammer Gitea. No DB cache table at v1; aprojax.cached_issueswould land in 2.f if perf bites. - Auth:
Authorization: token <GITEA_TOKEN>. The token is the mAi automation account (GITEA_TOKEN_AIin.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}/commentswith{"body":...}). - Create a new issue under a linked repo (
POST /repos/{o}/{r}/issueswith{"title":..., "body":...}). - Authorisation: writeback handlers reject any
repoform value that isn't linked to the item via agitea-repoitem_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 asgitea.ErrForbiddenand 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.
- Close an open issue (
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_itemswithparent_path='work'matches any item whosepaths[]contains a path equal toworkor beginning withwork.— multi-parent items surface from any ancestor.get_itemresolves either by uuid or by any path the row publishes;dev.paliadandwork.paliadreturn the same row.create_itemacceptsparent_pathsas a string array:[]for a root,['work']for single-parent,['work', 'dev']for multi.update_itemwith a non-nilparent_pathsreplaces the full parent list; pass the current list plus the new one to add a parent.treehonours multi-parent — the same uuid appears under each branch with its inherited path as the node'spathfield.
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/rpcreturns 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
httpserver entry for clients that speak HTTP+MCP natively. - A
commandserver 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.sqladdsprojax.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 innoteormetadata.bodyurl— 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_linkwithevent_date IS NOT NULLfor the item, orderedevent_date DESC, created_at ASC. - Each row shows the computed PER (
<primary-path>.<YYMMDD>[.<collision-tag>]), a ref-type badge, the ref_id (clickable forurl), 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 bycreated_atwithin the same date.
- Lists every
-
URL resolution for PER-cited paths:
handleDetailfirst tries the literalpath; if it 404s and the trailing segment looks likeYYMMDD, 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_linkaccepts an optionalevent_date: "YYYY-MM-DD". Existing callers without it keep working.linkView.event_datesurfaces the stored value on the response side. The conflict policy on duplicate(item_id, ref_type, ref_id, rel)isCOALESCE(new, old)for note/event_date so partial updates don't clobber an earlier date by accident. -
Anti-forgery on remove: the
/links/removehandler verifies the link'sitem_idmatches 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.projectstest 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):
- Open tasks — every open VTODO (
Status != COMPLETED/CANCELLED) from everycaldav-listitem_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/donewithcalendar_url + uid, flips the VTODO toCOMPLETEDvia the existing PUT path, busts the dashboard cache, and re-renders the full section (so the row vanishes and counts decrement). - Open issues — every open Gitea issue from every
gitea-repoitem_link, sortedupdated_at desc. Read-only (Gitea writeback parked). Reuses the existingGiteaDeps.Cache(3-min TTL) so repeated dashboard loads share Gitea hits with the detail page. - Recent documents — every dated
item_link(event_date IS NOT NULL) withevent_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:
- Stale projects — items with
'mai' = ANY(management)AND every linked Gitea repo'supdated_atolder 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).
- 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.
- Force-refresh button —
↻ refreshlink that adds?refresh=1to the current URL. The handler invalidates the matching cache key and re-runs the full aggregation. HTMX swaps the section in-place. - 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 ismax(layer(parent)) + 1, so a multi-parent item likepaliad(under bothworkanddev) 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:
maiblue,selfgreen,externalorange, 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 (
+Noverflow);×Nbadge top-right for multi-parent items. <title>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_linksmigrations indb/migrations/- Path trigger + tests
projax.items_unifiedview- 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.projectsschema (msupabase) — current state being adapted- mBrian
nodes/edgesschema — terminology source - otto session 2026-05-15 — inventory motivating this project