m's CalDAV server (dav.msbls.de, SabreDAV) now feeds projax via a thin
read-only-plus-create-on-demand integration. No background sync; tasks
fetched live on detail-page render.
New caldav/ package
- ListCalendars (PROPFIND Depth: 1, filters non-calendar collections)
- ListTodos (REPORT calendar-query for VTODO; hand-rolled iCalendar
parser for UID/SUMMARY/STATUS/DUE/PRIORITY/LAST-MODIFIED — RFC 5545
line-folding aware)
- CreateCalendar (MKCALENDAR, 405 → ErrCalendarExists for the "link
instead" branch)
- httptest-stubbed tests cover all four paths.
Store
- ItemLink shape + LinksByType / LinksByRefType / AddLink / DeleteLink.
AddLink upserts on (item_id, ref_type, ref_id, rel) so re-linking the
same calendar is idempotent.
Web
- GET /admin/caldav — discovery + auto-suggested matches + manual
linker. Suggestion = lowercased displayname == projax slug or title.
- POST /admin/caldav/link — insert item_links row.
- POST /admin/caldav/unlink — delete by link id.
- POST /i/{path}/caldav/create — MKCALENDAR at <base>/<slug>/, then
AddLink. On 405 (already exists), fall back to link-only.
- Detail page Tasks section: per-calendar block with open VTODOs +
collapsed completed (30d window). Errors per calendar logged and
skipped, so one bad calendar does not blank the page.
- nav adds /admin/caldav link.
main.go
- DAV_URL + DAV_USER + DAV_PASSWORD optional. Missing DAV_URL → CalDAV
off (admin page renders "not configured" notice). DAV_URL set but
user/pass missing → fail fast at boot.
docs/design.md gains §5 documenting the integration shape.
deploy/dokploy.yaml lists the two new secrets + the env var.
Phase 2.b (writeback / two-way / background sync) is parked.
19 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".
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. Tag filter chips at the top toggle a?tag=<csv>filter (an item is shown when it or any descendant carries every active tag).×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. - 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.
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 CalDAV/Gitea sync in v1 (read-only first)
- Real-time collaboration features
5. CalDAV integration (Phase 2, v1: read-only + create-on-demand)
m's CalDAV server lives at dav.msbls.de/dav/calendars/m/ (SabreDAV, Basic auth via DAV_USER/DAV_PASSWORD). projax v1 wires a small slice:
- 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. Errors per-calendar are logged and skipped — one bad list does not blank the section. - Create on demand (
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 and surfaces a one-line notice. - 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. - Out of scope for v1: editing VTODOs from projax, two-way creation, background sync, calendar colour/icon editing. Phase 2.b will layer write semantics; phase 2.c may add a TTL'd cache table if live REPORT-querying gets slow.
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.
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.
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