Status: Living document · last updated 2026-11-08 · migration head 0176 in main (authoritative list: server/src/db/migrations/index.ts)
This document describes the as-built system — what is actually running, not what was planned. For future phases, see docs/ROADMAP.md.
Anything finished but not yet merged to
maingets its own numbered section, cross-referenced from every section it changes, so "as-built" keeps meaningmain. There is currently no such section — §1.3 describes 0147, which has merged.
Companion Planning is a local-first, AI-assisted personal planning system. It runs as a private server on a laptop or desktop, reachable from any device over Tailscale, stores everything in local SQLite, and uses a local LLM (Ollama) for briefings, task extraction, grooming, and auto-scheduling. No cloud required; no account. Capacitor iOS and Android app shells (mobile/) wrap the same client and connect to a server you run — see README → Mobile app.
Shipped phases (A → AG, plus the post-AG work in §1.1 and the Categories⇄Balance
feature in §1.2; this list stops at migration 0135 — the end of §1.3 points to what came after): Tasks + scheduler · Rich semantics + time tracking · Recurrence · Calendar grid · Time-blocks + patterns + cockpit · Notifications + Web Push · Backups + export/import + audit · Identity + auth + connector infrastructure · Two-way calendar sync (Google) · Google OAuth 2.1 + MCP · AI scheduler + reactive reflow · Multi-device sync + offline writes · Life-planning layer (objectives, reflections, weekly review, calibration) · Phase O — the gamified five-star day (FOCUS card with linked/freeform focus + Today's Brief pop-up, a day note, computeStars() + nightly-frozen daily_recap stars, calendar heatmap; the standalone Morning Briefing section is retired) · Phase P — planner hygiene (a shared clear-range engine behind the CLI, POST /api/planner/clear, and a recurrence_horizon_days setting that bounds + auto-prunes both recurrence engines) · Phase Q — schema hygiene (FK enforcement guaranteed on every connection via a shared openDb(), FK-path index coverage, and owner-identity integrity rebuilds of flight_plan and daily_summaries — pre-multi-tenant) · Phase S — schema consolidation (dropped the dead plan_outcomes table + its cron; merged daily_summaries + day_stars into daily_recap; pattern_prefs + category_prefs into tag_prefs) · Phase T — multi-tenant owner-scoping (every table + route + service scoped to owner_id; owner-less rows retired) · Phase U — CI-Portal OIDC + local auth (session-cookie login: OAuth 2.1 + PKCE relying-party, or DISABLE_OIDC local username/password) · Phase V — password reset (single-use tokens; session_epoch invalidation) · Phase W — GraphQL CRUD layer alongside REST (complete; see §6a) · Phase X — MCP intent vocabulary · Phase Y — env-config (every env var documented; server/.env.example). Also in this window: task placement lifecycle (open↔scheduled), task→objective auto-promotion with height-based leveling, block-template application, and objectives joining the multi-device sync layer (0085–0087).
Phase Z Time-Sense (a Focus Ritual + estimate-calibration loop — EOD alignment, daily-brief fold-in, "Keep working"/"Schedule again", focus-cue reminders + vibrate, per-cue prefs, suggest-only estimate chip, voice loop) has landed behind the time_sense_enabled master flag (flag-off = behavior-neutral; migs 0106–0109). The calendar / UI feedback waves have also landed (QuickAdd mounted in Today, Planner de-scroll, Flight-Plan tabs + saved-blocks + search, three-axis calendar tag filter, container-outlines + click-to-add, select-date → day-type picker, collapsed night hours).
Phases AA–AE (2026-07, migs 0113–0117) close out the current line:
- AA — Memory-aware agentic layer (CI-Server
companion-memory, pull-first, behind thememory_pull_enabledflag): a read-only connector (connectors/memory.ts, fixture/live), thememory_suggestagent that stages upcoming events (thememory_window_dayspref sets how far ahead) as suggestions (the Inbox they first landed in was retired in AF, below), a memory-informed Daily Brief (AA2), memory-aware autoplan ordering + a history-based default estimate for memory-drafted tasks (AA3a/AA3b), and a one-waypush_memorybackground sync (AA4). Flag-off = byte-identical. - AB — Natural scheduling (
scheduled_for/task_timeare START intent that drives placement;due_atis a deadline only). - AC — Today + Tasks UI overhaul (Today: single header row — Today's Brief · Focus bullseye · Note as popovers; FAB restored; the DUE card, since turned into a button with a popup. Tasks: task-chip redesign with a merged direction-icon/done toggle, Routine grouping by routine, end-of-day archive, and a midnight
reset_routinesjob that reopens routine checklists). - AD — Calendar reliability (
task_timehonored across quick-add + flight-plan; container→task direction inheritance; Alt-drag free placement). - AE — Reflow correctness (snapshot-based reactive reflow: the clear set equals the re-place set, so a task edit can no longer wipe other auto-placed blocks; placement status parity; not-before-now clamp; locked-block guards). See §6.
Phases AF–AG + ship-readiness (2026-07, migs 0118–0126) — note 0119 is an intentional gap (see migrations/index.ts); the line continues past AG in §1.1:
- AF — Task management (migs 0120/0121): Objectives surface as a tab inside
/tasks(URL-synced?focus), tree API returns tasks-per-leaf, quick-add subtask, assign-to-objective on the task card. The Inbox surface is retired — ingestedpossible_tasksare deduplicated inline and auto-promoted into Tasks (no manual triage), surfacing in a date-derived New Today section until owner-local midnight;/inbox,/objectivesand/patternsare now redirects into/tasks(Objectives and Routines are tabs there), not standalone views; there is no/routinesroute. Includes AF-11 tag unification (routine-tag cleanup 0120, task-intention level move 0121). - AG — Multi-tenant RBAC (mig 0125):
owners.roledrives scopes at one choke point (lib/auth/scopes.tsroleToScopes, unknown role → read-only), replacing the hardcoded[READ, WRITE, ADMIN]triple. Three roles —operator(R+W+A) ·member(R+W) ·viewer(R); existing owners backfill tooperator, OIDC auto-provision defaults tomember(closes the auto-admin hole). Operator-gatedroutes/accounts.ts(GET/POST/PATCH/DELETE /api/accounts+/:id/invite) creates/manages accounts via password-less invite + reset-token link. - Ship-readiness + hardening (migs 0122–0124, 0126): seeded developer identity scrubbed (0122); dead
auto_modecolumn dropped, top-bar Suggest/Schedule toggle repointed totrust_mode(0123); in-app Google connection setup + auto-provisioned Web Push VAPID keys via theapp_configtable (0124); base category tags (Personal / Professional / Social) seeded (0126) — superseded by the six Balance-dimension categories, migration 0134, §1.2. License: PolyForm Noncommercial 1.0.0 (was CI-Commercial-1.0 until 2026-10).
Two tracks landed after the AG close-out. Neither has a phase letter; §1.2 (Categories⇄Balance, migs 0134–0135) continues the line, and the head has since moved past it (see the status line at top).
Also in this window, all flag-gated or additive: memory enrichment (POST /api/tasks/:id/enrich + the nightly enrich_tasks job, 0136), the close ritual + durable calibration history behind the Balance → Metrics tab (0137/0138), and Triality — three candidate next actions (Habitual · Contextual · Novel) ranked by local arithmetic over a balanced-ternary situation vector, a task motivation axis (me/us/them), an append-only outcome log, and a weekly weight-calibration job (each weight clamped to ½×–2× of its default, then renormalized so the slot's weights still sum to the default total), all behind scheduling_prefs.triality_enabled (default 0; migs 0139–0143; design record docs/triality-design.md). No generative model chooses or re-orders a pick; the one piece of I/O in the scoring path is a local embedding call that supplies half the novelty term when Ollama answers (bounded by a 40-candidate cap and a 4s stage budget, with a pure-arithmetic fallback when it does not) — so stopping Ollama can change the picks against identical SQLite state, and "deterministic" holds only for a fixed embedding availability. Flag-off = no routes, no UI, no suggestion computation; the calibrate_triality cron row is seeded unconditionally by 0143 and no-ops on an empty log, so it is not byte-identical: every owner still gets the weekly job_runs row, its job:run SSE broadcast, and the extra row in /jobs. Its surfaces are the Focus suggestion banner and the Balance → Metrics measurement tiles — no new routed view.
Life priorities + the Priorities Wizard (migs 0127/0128, #356). A life_priorities row per (owner, category) captures a self-audit — satisfaction (1–10), importance (1–10), target_hours_per_week. It backs two routed views: /priorities (the wizard) and /balance (the dashboard). scheduling_prefs.sleep_hours_per_week (0128, default 63 = 9h × 7) reserves sleep as unschedulable, so the wizard's weekly pool is 168 − sleep_hours_per_week. /balance reads the self-audit back — a Balance Index (how far satisfaction tracks importance) plus the allocated weekly targets. Self-contained, and narrower than it may sound: it reports stated intent, not measured time. At the time there was no actual-vs-target tracking (Balance.tsx read no time_entries/actual_minutes) or scheduling boosts — but the no task.category gap named here is now closed by the Categories⇄Balance feature (§1.2). Since then the Balance → Metrics tab (components/BalanceMetrics.tsx) reports measured counts, timed minutes and ratings, and the balance-targets wave (0164/0165) added a scheduled-vs-target shortfall read (services/balance-budget.ts), an auto-planning bias gated by scheduling_prefs.balance_bias_enabled, and the weekly balance_week_log snapshot.
Objectives archive + 48h purge (migs 0129/0130, #366 and follow-ups #367–#372). DELETE /api/objectives/:id no longer hard-deletes — it archives. This is the single most load-bearing behavioral change in this window, and the invariant it rests on is easy to break:
- Soft delete.
archived_atis stamped; the row is kept indefinitely.statusis untouched — the archive is orthogonal to status, which is what makes restore lossless (and why a cadence job must checkarchived_at, not juststatus='active'). - The cascade. Archiving fans out over the live subtree in one transaction, stamping each descendant's
archive_root_idso the subtree restores as a unit (cascadeArchiveToDescendants). Atomicity is required, not nice-to-have:buildTreeroots any node whose parent is filtered out, so a live child left under an archived parent silently resurfaces as a top-level goal. - THE INVARIANT: no live objective may have an archived parent. Every read path filters
archived_at IS NULL; every write path refuses an archived parent (liveParentExists→parent_not_found); the sync path clamps to it (§12). - Purge is separate and explicit.
PURGE_WINDOW_HOURS = 48.POST /:id/purgestamps an absolutepurge_afterdeadline across the subtree;DELETE /:id/purgecancels it. The hourlypurge_objectivesjob (0130) reaps rows past the deadline — the only path to permanent removal. - Routes:
GET /api/objectives?archived=1(flat archive list — an archived subtree is not a renderable tree),POST /:id/restore(root-only; a cascaded member must restore from its root),POST /:id/purge,DELETE /:id/purge. - Exception — auto-generated scaffolding is still hard-deleted.
unpromoteIfEmpty/dropSourceObjective(services/task-promotion.ts)DELETEa pristine auto-promoted objective rather than archiving it, so the archive doesn't fill with rows the owner never chose to create. Guarded byisPristine(top-level, no sub-objectives).
Unifies the old block-only "Time Block Category" axis with /balance's six Core Dimensions,
so a Category is a tracked Balance dimension by name, on both tasks and blocks, and
drives an optional per-element striped background. Full session record + owner decisions:
docs/HANDOFF-2026-07-20-categories-and-ui.md (closed out).
- Model (0134). The
kind='category'tag axis is purged and re-seeded to exactly the sixlife_prioritieskeys —health / career / relationships / growth / leisure / finance— eachis_base=1, hue-matched to a--tag-Ntoken viatag_prefs. Add/remove of categories is behindclient/src/lib/flags.tsCATEGORIES_EDITABLE(false this delivery — the six are fixed; rename/recolour stays allowed). New tableentity_categories(entity_kind, entity_id, category_tag_id, position), PK on all three,position(0–2) ordering a 1–3 multi-select per task/block — the single legacytime_blocks.category_idcolumn couldn't express that.services/tags.ts:setEntityCategories/entityCategoryNames/categoryIdsForOwner(+ batchedentityCategoryNamesByIds, thetaskTagNamesByIdsidiom — no N+1 on list reads). - Capture (0135 + wiring).
0135_categories_capturebackfills any survivingtime_blocks.category_idintoentity_categoriesat position 0, then addstexture_enabled(DEFAULT 1) +texture_color_tokento bothtasksandtime_blocks— the per-element texture on/off + colour override.category_idstays a column (back-compat reads) but is retired from the write path:createBlock/updateBlockbridge a legacycategory_idinput intoentity_categoriesand null the column on touch;entity_categoriesis authoritative. The 1–3 picker lives on both ElementWizard tabs;enrichQuick(agents/quickadd.ts, handed the owner's vocabulary bysuggestibleVocabulary) classifies 1–3 best-guess categories on a separate axis from intentions, so a category name can never leak intosecondary_tags. - How quick-add's category pick learns (Phase 8, apply and capture). Two routes classify:
POST /api/tasks/quick/parse(the wizard's Describe-it preview, zero writes) andPOST /api/tasks/quick(creates the task; no client calls it today). Both go throughenrichEntry(routes/tasks.ts), which handsenrichQuickthe owner's own corrections asclassificationHint, forcategoriesand formotivation. The hint is frequency-only (embedFn: null): one lesson per most-corrected value, at mostRETRIEVE_K= 3, so the interactive path never waits on the embedder (an embed there costs a status read plus the embed; in the measurement the similarity arm's model calls ran slow on a two-model box, 109 of 120 in its last run, which fits an evicted classify model but does not show one). It is a pure read of the lastcalibrate_classificationrun and empty belowCORRECTION_FLOOR= 2 corrections, in which case the prompt is the stock prompt byte for byte. It sits last in the prompt, after the "never invent" line and directly beforeEntry:, not beside its field (beside it, a small model stopped its JSON early;docs/classification-hint-measurement-2026-11.md). The direction retry gets no hint. Capture runs the other way:/quick/parsereturns an opaqueclassification_claim({ text, categories }) when the model chose categories the parse did not carry, the wizard echoes it on its add-modePOST /api/tasks, andsettlePreviewClaimwrites oneclassification_logrow after the create succeeds: closed at once if the saved list, in order, differs from the pick, open (pending 24 h) only if the ordered list is identical. The claim is unsigned and bound to the new task's own title, the caller's own category names and the saved categories read back from the database, so a forged one can only affect the caller's own evidence; a claim that fails any check is dropped and the create is unchanged. A preview that is never saved writes nothing. The index has three readers: quick-add and the preview (frequency, last in the prompt), the memory fill-in (services/task-enrich.ts, similarity when an embedder answers, else frequency) and the Monday scorer's held-out prompts (scoreHeldOut, similarity; a measurement path, not a user-facing one). None of this is online learning: a correction changes no prompt before the next weekly run, short of an admin running the job early from the Jobs page or an owner resetting the index. The wizard records the category pick only, so no in-app path logs a who-it's-for pick: that comes fromPOST /api/tasks/quickand from memory enrichment (off unlessMEMORY_MODEis set), and the who-it's-for hint was not measured. - Derive rule (the key relationship). A block's effective categories are its own
entity_categoriesrows if any, else its linked task's — resolved server-side (blockCategoryNames,services/blocks.ts) and reused client-side (the same rule backs the Planner chip's texture and the Calendar overlay's category filter). Consequence: an auto-placed task block needs no category write of its own — recategorizing the task re-stripes every block it holds, free. - Renderer.
client/src/lib/textures.tselementTextureStyle(el, tags, prefs, motifFallback?)is the one resolution rule:texture_enabled=0→ none →texture_color_token(user override, single hue) →categories(even stripes, arepeating-linear-gradientinterleaving each category's colour token line-by-line inpositionorder, 1px line / 2px gap) → the legacy routine/pattern motif tile (kept, for Habit/Ritual members) → none.TEXTURE_OPACITYhalved0.06 → 0.03(stripes render at 2× to compensate — a 1px line carries roughly a third the ink of the old motif tile). Un-gated from routine-only to every task card and planner chip. - Surfaces. TaskCard's secondary-axis chips are now the task's categories (colour-dot
- name), not intention
secondary_tags— intentions stay inentity_tags(still drivetag_categories/tag_directionsgrouping and auto-tag) but are off the card./balanceCore-Dimension cards get an "N open" button → a client-joined modal of that dimension's open tasks (no new query — tasks already carrycategories). The Calendar overlay's Category row is now functional (lib/overlay.tsnamesPassreads the block's effective categories — previously it matched category names against task tags, where they never appear, so the row was inert) plus a brand-monochrome ↔ colourful-per-tag palette toggle. Tags view: a per-chip Category<select>on both tabs (writestag_categories), alongside the existing drag-to-refile.
- name), not intention
In
main. Sections §5.2, §6, §7, §7.1 and §8 carry a pointer back here for the full narrative.
Three related changes to how work arrives and gets ordered, plus one real bug found on the way. The through-line is arrival time: two settings bound how much of each source a pull reads, one precedence change fixes what "arrived today" means in the UI, and one fill order lets arrival drive placement.
Ingestion windows (0147). Two per-owner columns on scheduling_prefs, both surfaced in
Settings → Connections:
| Column | Range | Default | Direction | Bounds |
|---|---|---|---|---|
memory_window_days |
1–30 | 2 | ahead | how far forward the Companion Memory pull reads |
email_lookback_days |
1–90 | 7 | back | how far back a Gmail ingest may reach |
The two directions are deliberate and not symmetric: memory suggestions mirror upcoming events into tasks, so a backward memory window would stage work for things that already happened; an inbox is only ever read backwards.
- The memory half is a no-op upgrade — default 2 is exactly the 48h that
agents/memory-suggest.ts(MEMORY_WINDOW_HOURS, now deleted) andagents/summarize.ts(memoryBriefExcerpt) each hardcoded separately.memoryWindowMs(owner)inservices/memory.tsis the single authority both read, so one setting moves the suggestion agent and the daily brief together. A non-positive stored value is treated as unset, so a bad row can never collapse the window to zero and silently stop all suggestions. - The email half is not a no-op. The
gmail_sincecursor had no floor at all, so a first ingest — or the first run after a long outage — read as far back as the MCP Gmail tool would return.ingestEmailsnow takessince = max(watermark, now − N days)viagmailLookbackFloor(). On the healthy 15-minute cadence the watermark is always newer, so the steady state is byte-identical. An explicitopts.sinceis a manual replay and bypasses the floor deliberately — the caller has named the window. - Validation lives in
services/prefs.ts(MEMORY_WINDOW_DAYS_RANGE,EMAIL_LOOKBACK_DAYS_RANGE,inRange) so REST and GraphQL cannot drift, and it rejects (422 /BAD_USER_INPUT) rather than clamping — the same rule asrecurrence_horizon_days, and for the same reason: a clamped value leaves the Settings input showing a number the server never stored. Values are alsoNumber()-normalized there, becausefromApiis a blind key rename and a string"5"would otherwise reach Drizzle and land in an INTEGER column. - Known trade-off: the email floor applies always, not only on first run. A reconnect after a long outage pulls the configured window and the watermark jumps past the older backlog. That is the plain meaning of "look back N days", but it is a deliberate skip in a pipeline otherwise engineered never to skip.
Tasks-tab precedence. Membership order is Routine › Scheduled › New Today ›
Open (was Routine › New Today › Scheduled › Open) in client/src/lib/task-sections.ts
partitionTaskSections. Scheduling a task the same day it was created now moves its card
to Scheduled immediately; previously it sat in New Today until local midnight, so the
placement produced no visible movement and the section read as "created today" rather than
"not yet dealt with". New Today now means arrived today and still needs a decision.
The unscheduled New Today → Open rollover at local midnight is unchanged and still pure
derivation — no column, no job. headerOpenCount's isScheduled parameter is dropped:
it existed only to strip scheduled tasks back out of New Today under the old order, and the
partition now drains them first — the header NUMBER is unchanged by the swap.
New Today fill order. fill_order carries a third value, new_today, alongside Priority
and Due Date in the flight-plan toggle. Today's arrivals take the earliest slots and each
half stays deadline-first, so it layers on overdue rather than replacing it. Keyed on
the owner's real today, not the date being placed — otherwise planning ahead would make
the mode a silent no-op, since nothing is ever created on a future date. Membership matches
the client's New Today partition (created_at converted to the owner's local day, never a
raw UTC compare); a NULL created_at sorts with the older half, so an undated row never
displaces a task the owner can see is new.
Wired into both ordering paths, which is necessary rather than thorough:
placeFlightPlan skips its own fill_order branch whenever a daily_plan exists for the
day (engine/autoplan.ts orderByNewToday), so a value only the engine understood would
be inert on exactly the days the owner planned deliberately; deterministicIndices
(agents/plan.ts) carries a matching band — after theme/memory, before deadline/priority —
which is the same parity the existing two values already have.
Bug: CURRENT_TIMESTAMP parsed as local time. tasks.created_at is defaulted by
SQLite's CURRENT_TIMESTAMP, which stores 'YYYY-MM-DD HH:MM:SS' — a UTC instant with
no zone designator that new Date() parses as LOCAL. Behind UTC that shifted every
timestamp by the server offset, so a task created after ~17:00 local never appeared in New
Today on the day it actually arrived; it showed up the next day. Every task row in a real
database uses this spelling. Fixed at both ends with the identical rule so the Tasks tab
and the new fill order cannot disagree about the set: sqliteUtcToISO() (lib/tz.ts)
folded into localDateInTZ, and the same normalization in isCreatedOnDay client-side. A
no-op for the proper ISO strings every other caller passes.
After 0147. §1 stops narrating at migration 0147. Migrations 0148–0176 are recorded elsewhere: the "Added" columns of §5.2 and the classes of §5.3 (people on work 0151–0154 · the classification, dismissal, plan-loop and placement learning loops 0149–0150, 0157–0158, 0162–0163 and 0166–0167 · the 1–5 close-ritual score 0161 · the balance-targets wave 0164–0165 · sign-up codes 0172 · the classification log's entity kind 0173 · the Triality weights source 0174 · the Triality motivation stamp 0175 · the weekly recap's me / us / them setting 0176), the job list in §7.1, the per-wave ticket plans under docs/factory/, docs/people-on-work.md, docs/AH-focus-timeblocks.md (0148) and CHANGELOG.md.
| Layer | Choice | Notes |
|---|---|---|
| Frontend | SolidJS 1.9 + Vite 8 | Fine-grained reactivity, small bundle |
| Styling | Tailwind CSS 4 + @companionintelligence/tokens |
CI-Hub design tokens as CSS variables (client/src/index.css); light/dark by a .dark class, dark-first default |
| Backend | Koa 3 (Node ≥22) | Minimal async middleware; run with tsx (no compile step). .nvmrc pins Node 26.3.0 and the Docker image is node:26-bookworm-slim |
| DB | SQLite via better-sqlite3 12 |
WAL mode, embedded, zero-ops |
| Vector search | sqlite-vec (optional) |
Transient vec0 KNN index for possible-task dedupe; a plain cosine loop takes over when the extension can't load |
| Postgres (in progress) | pg + TypeORM + dbmate-format SQL migrations |
A port foundation, not a deployment option yet: opt-in with DATABASE_URL (migrations run at boot) and STORE_BACKEND=postgres (the cutover switch, default sqlite). The app still runs on SQLite, and the 56-table Postgres baseline was collapsed from the SQLite migrations through 0147, so the 18 tables and the columns added since are not in it — see server/db/postgres/README.md |
| Migrations | Plain TypeScript functions | Sequential, idempotent; applied versions recorded in the schema_migrations table |
| ORM | Drizzle ORM schema mirror + raw SQL | schema.ts mirrors 45 of the 74 tables (guarded by schema-parity.test.ts); queries are mostly raw SQL through the async store() facade (db/store.ts), which wraps better-sqlite3 |
| GraphQL | Apollo Server 5 + @as-integrations/koa |
Schema-first SDL at POST /api/graphql, alongside REST; custom complexity limiter; graphql@16 (Phase W, complete) |
| LLM runtime | Ollama (local) | Graceful degradation when offline; model tiers resolve from what is installed (lib/models.ts) |
| MCP | @modelcontextprotocol/sdk |
Gmail + Google Calendar over OAuth |
| Cron | croner + scheduled_jobs table |
Persistent job registry |
| PWA | Web App Manifest + hand-written service worker (client/public/sw.js) |
Installable; Web Push (VAPID) and notification-click routing only — deliberately no fetch/cache handler, so there is no offline shell |
| Mobile | Capacitor 8 (mobile/ios, mobile/android) |
Shells around the same client build that connect to a server you run; live updates by polling GET /api/sync/changes (an EventSource can't carry the session cookie there). See README → Mobile app |
| Auth | Session-cookie login (OIDC/PKCE RP + local argon2id) + hashed API-key | Mints a signed, httpOnly cp_session on sso login; local accounts can self-sign-up with a single-use invite code (AUTH_SIGNUP, default invite); hashed x-api-key for standalone; no per-request Bearer |
| Crypto | AES-256-GCM (CP_KEK_B64) |
OAuth tokens sealed in connection_tokens |
nodemailer |
Password-reset and invite emails; SMTP when SMTP_HOST is set, otherwise a console transport that logs the message |
|
| Error reporting | Sentry (@sentry/node, @sentry/browser) |
Opt-out: on whenever a SENTRY_DSN is configured, off with CI_TELEMETRY=off or CI_LOCAL_ONLY=true — see docs/operations/error-reporting.md |
| Tests | Vitest 4 (server + client), Playwright | Server suites in server/test/; Playwright smoke specs against the built container in tests/container/ |
graph TB
subgraph Devices["User Devices · via Tailscale"]
Phone["Phone / Tablet"]
Laptop["Other Laptops"]
Browser["Any Browser"]
App["iOS / Android app<br/>(Capacitor shell)"]
end
subgraph Host["Local Host"]
subgraph Koa["Koa.js — port 3848"]
API["REST API<br/>48 route modules"]
GQL["GraphQL<br/>/api/graphql (Apollo, alongside REST)"]
SSE["SSE /api/events<br/>real-time push"]
Engine["Scheduling Engine<br/>autoplan · reflow · review"]
Cron["Croner Jobs<br/>25 cron jobs (26 kinds)"]
Migrate["Migration runner<br/>0001 → head on boot"]
end
FE["SolidJS PWA<br/>(built into server/public/)"]
SW["Service Worker<br/>Web Push (no offline cache)"]
DB[("SQLite WAL<br/>companion.db")]
end
subgraph AI["AI (optional — Ollama)"]
Small["fast tier (default qwen2.5:0.5b)<br/>extraction / triage"]
Medium["quality tier (default gemma3n:e4b)<br/>briefings / planning"]
Embed["embed tier (default nomic-embed-text)<br/>dedupe embeddings"]
end
subgraph Google["Google (live mode only)"]
GCal["Calendar MCP"]
Gmail["Gmail MCP"]
end
subgraph CIServer["CI-Server (memory_pull_enabled)"]
Mem["companion-memory<br/>graph API (read-only)"]
end
Phone -->|HTTPS / Tailscale| FE
Laptop -->|HTTPS / Tailscale| FE
Browser -->|HTTPS / Tailscale| FE
App -->|"HTTPS / Tailscale · polls /api/sync/changes"| API
FE <-->|REST fetch| API
FE <-->|GraphQL| GQL
FE <-->|SSE stream| SSE
SW <-->|Web Push| API
API --> Engine
API --> DB
GQL --> DB
Cron --> DB
Cron -->|Ollama HTTP| Small
Cron -->|Ollama HTTP| Medium
Cron -->|Ollama HTTP| Embed
Cron -->|MCP stdio| GCal
Cron -->|MCP stdio| Gmail
Cron <-->|"pull graph / push events"| Mem
Engine --> DB
Migrate --> DB
CI-Planning/
├── client/ SolidJS PWA
│ ├── public/ sw.js (push-only service worker) · manifest.webmanifest · icons
│ └── src/
│ ├── components/ Shared UI components
│ ├── views/ One file per routed screen (Today, Focus, Calendar, AllTasks,
│ │ Tags, PrioritiesWizard, Balance, Settings, Jobs, Login, Signup,
│ │ ResetPassword) + ConnectServer, the app shells' first-run screen
│ ├── stores/ Solid signals/resources shared across views
│ ├── sw/ Service-worker registration (the worker itself is public/sw.js)
│ └── lib/ api.ts · dates.ts · objective-promote.ts · server-config.ts ·
│ live-poll.ts · textures.ts
├── server/
│ ├── db/postgres/ dbmate-format Postgres migrations + README (the opt-in port, §2)
│ ├── test/ Vitest suites
│ └── src/
│ ├── db/
│ │ ├── migrations/ 0001_baseline → 0176 (172 files), ordered in index.ts (0009, 0119,
│ │ │ 0159 and 0160 have no file; 0119 is an intentional gap — do not
│ │ │ "fix" it)
│ │ ├── index.ts openDb (open.ts) + uuid7 UDF + sqlite-vec + runMigrations (migrate.ts)
│ │ ├── schema.ts Drizzle mirror of 45 of the 74 tables (§5.2)
│ │ ├── store.ts async data facade — the store() every query goes through
│ │ └── pg.ts · migrate-pg.ts · datasource.ts · entities/ Postgres port (opt-in, §2)
│ ├── engine/ autoplan (incl. flight-plan placement) · scheduler · review ·
│ │ auto-defense (reactive reflow) · day-reflow · recurrence ·
│ │ reminders · stars · clear-range
│ ├── routes/ REST controllers (one file per resource; 48 modules mounted by index.ts)
│ ├── graphql/ Apollo schema-first SDL + resolvers (Phase W, alongside routes/)
│ ├── sync/ apply.ts — the field-LWW merge engine (§12)
│ ├── jobs/ Croner job definitions (26 kinds; 25 cron-scheduled) ·
│ │ catch-up.ts: boot-time make-up of missed learning passes (§7.1)
│ ├── services/ 53 modules — the shared owner-scoped layer REST + GraphQL
│ │ both call (tasks · blocks · objectives · task-promotion ·
│ │ priorities · stars · timer · people · vapid-config · …)
│ ├── connectors/ Google OAuth 2.1 + Calendar/Gmail MCP bridge · CI-Server companion-memory (memory.ts)
│ ├── mcp/ MCP client/session for the Google tool surface (Phase X) · Ollama client (ollama.ts)
│ ├── agents/ ingest · groom · sync reconciler · memory-suggest · plan (authoring) · summarize · quickadd
│ ├── cli/ issue-key.ts — API-key issuing (pnpm --filter server key:issue)
│ ├── scripts/ migrate · snapshot · restore · seed-test-data · check-schema-drift ·
│ │ clear-planner-from · import-plan
│ └── lib/ config · crypto · dates · tz · logger · ids · auth/ (scopes, session, …) · mail · telemetry
├── mobile/ Capacitor iOS and Android shells (ios/, android/) — README → Mobile app
├── tests/container/ Playwright smoke specs against the built container (run-local.sh)
├── video/ Product-video build (storyboard.json + captured screenshots), built locally
├── scripts/factory/ factory.sh — gh wrapper for the Factory ticket workflow (docs/FACTORY.md)
├── docs/ ROADMAP · GLOSSARY · phase specs · diagrams · factory/ wave plans
├── ops/ litestream.yml · launchd/ plists · systemd/ unit · scripts/ (install and helper scripts)
├── .github/workflows/ ci · docker-publish · mobile-build · style-canon-lint
└── Dockerfile · docker-compose.yml · .env.example
erDiagram
OWNERS ||--o{ TASKS : owns
OWNERS ||--o{ OBJECTIVES : owns
OWNERS ||--o{ REFLECTIONS : owns
OWNERS ||--o{ TIME_BLOCKS : owns
OWNERS ||--|| SCHEDULING_PREFS : configures
TASKS ||--o{ OBJECTIVE_TASKS : linked_via
OBJECTIVES ||--o{ OBJECTIVES : parent_of
OBJECTIVES ||--o{ OBJECTIVE_TASKS : progressed_by
TASKS ||--o{ TIME_BLOCKS : scheduled_as
TASKS ||--o{ ENTITY_TAGS : tagged_via
TAGS ||--o{ ENTITY_TAGS : labels
TASKS ||--o{ FLIGHT_PLAN : staged_in
TASKS ||--o{ DAILY_PLAN : planned_in
TIME_BLOCKS ||--o{ EXTERNAL_EVENTS : mirrored_by
OWNERS ||--o{ POSSIBLE_TASKS : proposed_for
OWNERS ||--o{ REMINDERS : scheduled_for
OWNERS ||--o{ ESTIMATE_CALIBRATION : calibrated_by
OWNERS ||--o{ DAILY_RECAP : recapped_in
OWNERS ||--o{ LIFE_PRIORITIES : audits
OWNERS ||--o{ PEOPLE : keeps
PEOPLE ||--o{ ENTITY_PEOPLE : named_via
TASKS ||--o{ ENTITY_PEOPLE : involves
OBJECTIVES ||--o{ OBJECTIVES : parent_ofis the live tree only. An archived node (archived_atset) keeps itsparent_idand itsobjective_taskslinks — nothing is severed — but every read path filters it out. See §1.1.
ORM = modeled in server/src/db/schema.ts (the Drizzle mirror, guarded by
schema-parity.test.ts). Raw = raw-SQL only — invisible to Drizzle queries and to that
parity test. 74 live tables total (45 ORM, 29 raw; the schema_migrations bookkeeping
table is not counted), grouped below by concern; superseded by the full UML class diagram
in §5.3.
| Table | Purpose | ORM | Added |
|---|---|---|---|
owners |
User identity — auth columns external_subject/username/password_hash (0101), reset columns backup_email/session_epoch (0103), role (operator|member|viewer, 0125) |
✓ | 0001, 0101, 0103, 0125 |
api_keys |
Hashed x-api-key credentials for AUTH_MODE=standalone |
✓ | 0049 |
auth_states |
Pre-login OIDC PKCE state (issued at /login, consumed at the callback) |
✓ | 0102 |
oauth_states |
Single-use OAuth 2.1 PKCE/state rows, consumed at callback | ✓ | 0058 |
connection_tokens |
Encrypted OAuth tokens (AES-256-GCM, CP_KEK_B64); provider ∈ Google + ci_memory (per-owner CI-Server memory creds, AA0/AA0b) |
✓ | 0050 |
password_reset_tokens |
Single-use local-owner password-reset tokens (Phase V) | ✓ | 0104 |
app_config |
Server-owned singleton config rows set from inside the app rather than env — in-app Google connection setup + the auto-provisioned Web Push VAPID keypair (generated + persisted on first boot; env pins win) | raw | 0124 |
signup_codes |
Single-use, expiring (7-day TTL) invite codes for local self-serve sign-up (AUTH_SIGNUP=invite, the default): code_hash = sha256 of the raw code, which is shown to the operator once at mint time and never stored; used_at/used_by stamp the one-shot claim, revoked_at an operator withdrawal; both owner FKs are ON DELETE SET NULL |
✓ | 0172 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
tasks |
Core work atom — UUIDv7 PK, status, priority, subtype, recurrence. texture_enabled/texture_color_token (0135) — per-element striped-texture override (§1.2); completion_score/completion_note/reaction_at close-ritual columns, all nullable (0137, rescored to a 1–5 score by 0161 with 0 = "prompted and declined"); motivation (me|us|them, NULL = unlabeled — and NULL is never read as us) + motivation_source (user|llm; a user label is never re-tagged) (0141) |
✓ | 0001, 0135, 0137, 0141, 0161 |
task_dependencies |
Blocked-by edges between tasks | ✓ | 0005 |
time_entries |
Actual work logged per task; running timer = ended_at IS NULL |
✓ | 0007 |
possible_tasks |
Inbox — AI-proposed items pending promote / dismiss; owner-scoped (proposed_owner_id, source_kind, source_ref) uniqueness (0114) + proposed_estimated_minutes (0114) + proposed_scheduled_for/proposed_task_time START intent (0115) |
✓ | 0001, 0114, 0115 |
possible_task_tags |
Tags staged on an inbox item, carried over on promote | ✓ | 0001 |
flight_plan |
Two-step Auto staging tray — task list ordered before scheduling; owner_id TEXT NOT NULL (0093) |
✓ | 0016, 0093 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
time_blocks |
Named time spans — task · meeting · focus · break · buffer; owner_id NOT NULL (0099). category_id/color_token (0069) are legacy reads only — writes go through entity_categories (0134/§1.2); texture_enabled/texture_color_token (0135) per-element override; responsible_pinned/beneficiaries_pinned (0153/0154) — a task-backed block inherits its task's people per role unless it has rows of its own or the flag makes its own (possibly empty) set authoritative; inbound calendar mirrors are always is_locked (0148 backfill) |
✓ | 0015, 0099, 0135, 0148, 0153, 0154 |
block_templates |
Reusable Time-Block templates with saved task lists | ✓ | 0022 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
tags |
Cross-cutting labels; kind: direction or intention or category. category is re-seeded to the six Balance dimensions, is_base=1 (§1.2); owner_id (0100) |
✓ | 0001, 0100, 0134 |
tag_directions |
Many-to-many: intention tags grouped under direction tags | ✓ | 0043/0105 |
tag_categories |
Many-to-many: intention tags grouped under category tags — verbatim mirror of tag_directions on the second (Balance) axis |
✓ | 0069 |
category_direction_optouts |
Per-category direction SCOPE, opt-out polarity — a row means a direction is turned OFF for that category; empty = all directions on | ✓ | 0069 |
entity_tags |
Polymorphic tag store for tasks, blocks, routines (CON-3); created_at NOT NULL DEFAULT CURRENT_TIMESTAMP (0110, table rebuilt); no FK on entity_id — integrity is app-enforced (services/tags.ts) |
✓ | 0033, 0110 |
entity_categories |
Polymorphic category store for tasks/time_blocks (entity_kind, entity_id, category_tag_id, position), 1–3 per entity, position (0–2) = stripe order. A block's effective categories are its own rows, else its linked task's (§1.2); no FK on entity_id, same as entity_tags |
✓ | 0134 |
tag_prefs |
Per-owner × per-tag config by kind (direction/category): shared color_token/default_minutes + direction-only auto_fill/reminder_offsets/icon_shape; merged pattern_prefs + category_prefs. category rows are the six Balance-dimension hues (0134); show_open_time (0170, direction-only, default 1) — whether a direction's free windows appear on the Today Open Time card |
✓ | 0039, 0069, 0097, 0134, 0170 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
patterns |
Routines — typed, reusable task collections (Habit ◯ / Routine ▢); color_token (0133) is a per-item colour, NULL = inherit the type's |
✓ | 0013, 0133 |
pattern_types |
Routine taxonomy — load-bearing, do not rename | ✓ | 0019 |
pattern_tasks |
Routine ↔ task many-to-many | ✓ | 0013 |
pattern_blocks |
Direction-scoped scheduling windows (workday/weekend) | ✓ | 0037 |
pattern_day_optouts |
"Skip this routine on this date" — one row per (owner, pattern, date) | raw | 0074 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
scheduling_prefs |
Focus/break bounds, timezone, trust dial trust_mode, planning_mode (basic|smart; independent of the trust dial since 0171), fill_order (priority|overdue|new_today, §1.3), model overrides, feature flags, recurrence_horizon_days (default 7), time_sense_enabled (0106) + per-cue focus prefs focus_ritual_enabled/haptic_cues_enabled/ambient_color_enabled/calibration_loop_enabled/z_experiment_arm (0108), view_prefs cross-device calendar view-state (0111), memory_pull_enabled master flag for the CI-Server memory layer (default 0, 0113), sleep_hours_per_week (0128, default 63 = 9h × 7 — reserved as unschedulable; the Priorities Wizard's pool is 168 − this), triality_enabled master flag (default 0, 0139), and (§1.3, 0147) the ingestion windows memory_window_days (1–30, default 2 — how far AHEAD the memory pull reads) and email_lookback_days (1–90, default 7 — how far BACK a Gmail ingest may reach), onboarded_at (0132 — NULL until the first-run wizard is completed or skipped), llm_thinking_enabled (0155, default 0 — opt-in "thinking" pass on generation calls), capacity_hours_per_week (0156, default 56 = 8h × 7 — the AI-authoring capacity hint; it replaced workday_start/workday_end, which stay in the table but are neither read, written nor modeled in schema.ts) balance_bias_enabled (0164, default 1 — gates the auto-planning bias toward life dimensions behind their weekly target) and motivation_recap_enabled (0176, default 0 — adds the me / us / them block to the weekly recap) |
✓ | 0001, 0091, 0106, 0108, 0111, 0113, 0128, 0132, 0139, 0147, 0155, 0156, 0164, 0171, 0176 |
day_types |
Custom day types + per-date assignment (T-SCHED-3); workday/weekend builtins, rename-only |
✓ | 0068 |
day_of_week_map |
Recurring weekly default: dow (0=Sun…6=Sat, UTC noon) → day-type key | ✓ | 0068 |
day_assignments |
Per-date override: a specific YYYY-MM-DD → day-type key (beats the weekly map) |
✓ | 0068 |
connection_prefs |
Per-calendar display prefs — color_token/texture by (owner, provider, account), deliberately NOT FK'd to connection_tokens.id |
raw | 0145 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
daily_plan |
AI-authored task order for a day (outcome stamp for plan tracking) |
raw | 0062, 0084 |
plan_hints |
NL scheduling hints fed to the planner, one row per (owner, date, kind, tag) | raw | 0071 |
replan_suggestions |
Pending "re-plan with AI?" nudges on big-disruption reflows — one row per owner | raw | 0067 |
planning_sessions |
The planning-session log — one row per "Plan my week" session; the whole conversation is the transcript JSON array, no per-turn events table |
✓ | 0131 |
daily_recap |
One row per (owner, date): the day-briefing cache (prose/stats/focus/inputs_hash) + the frozen five-star score (star_*/total/frozen_at); today lives, past days permanent once frozen. Merged daily_summaries + day_stars |
raw | 0089, 0094, 0096 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
objectives |
Hierarchical life goals — Goal › Project › Objective by height; source_task_id marks auto-promoted nodes; version/field_ts for sync. Archive (0129): archived_at (non-null ⇒ soft-deleted, absent from the live tree, status untouched), archive_root_id (the node whose archiving cascaded to this row; NULL on the root), purge_after (absolute reap deadline; server-owned — never client-authored) + ix_objectives_purge_after. color_token (0133) is a per-item colour, NULL = default. See §1.1 |
raw | 0081, 0085, 0086, 0087, 0129, 0133 |
objective_tasks |
Objective ↔ Task linkage for progress roll-up | raw | 0081 |
reflections |
FOCUS card slots — today focus + up to 3 weekly goals per ISO week; optional source link to a Task/Objective (source_kind/source_ref); also the one-per-day kind='day_note' row |
raw | 0080, 0088 |
life_priorities |
Self-audit per (owner, category) — satisfaction 1–10, importance 1–10, target_hours_per_week. Backs /priorities (wizard) + /balance (dashboard); UNIQUE(owner_id, category). category is a plain TEXT key (no FK) — shares its NAME vocabulary with tags.kind='category' (0134) but the two are linked only by string equality, per owner |
✓ | 0127 |
balance_week_log |
Balance-targets wave (bal-6) — one row per (owner, week_start, category), UNIQUE. A weekly snapshot_balance job freezes target_hours/scheduled_minutes/timed_minutes for the week just closed, INSERT OR IGNORE (first-freeze-wins). category is bare TEXT, same string-equality link to life_priorities/tags as above — no FK. Its only reader is balanceHistory() (services/balance-snapshot.ts, GET /api/priorities/history), which feeds the details drawer's "met target N of last M weeks" line; the live bias (balance_bias_enabled, 0164) reads the instruction (life_priorities + this week's calendar), never this log |
raw | 0165 |
Who does an item and who it is for — see docs/people-on-work.md.
| Table | Purpose | ORM | Added |
|---|---|---|---|
people |
Per-tenant person directory. owner_id is tenancy (whose directory); account_id is the login account the person is, when they have one (NULL for most people; account_id = owner_id marks the owner's own row, so "is self" is derived, never stored). confirmed = 0 for rows ingest created provisionally; source ∈ manual/account/gcal/gmail/llm. UNIQUE (owner_id, display_name), plus a partial UNIQUE (owner_id, account_id) |
✓ | 0151 |
person_emails |
The deterministic matching key: a lowercased address resolves to a person with no model in the loop. PK (owner_id, email) — unique per tenant, not globally |
✓ | 0151 |
entity_people |
Both people-sets an item carries, for tasks and time blocks: role (responsible = who does the work · beneficiary = who it is for) is in the PK because one person is usually in both; source ∈ user/llm/calendar/email; position 0 within responsible = the lead. No FK on entity_id, same as entity_tags/entity_categories |
✓ | 0151 |
possible_task_people |
People detected on a suggestion, carried onto the task at promote — the people twin of possible_task_tags. PK (possible_task_id, person_id, role); source ∈ llm/calendar/email; per-row confidence (responsible comes from attendees/headers, beneficiary from the model) |
✓ | 0152 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
estimate_calibration |
Per-owner bias stats (p25/p50/p75 of actual/estimated ratio); scope columns (0107). Regenerable rollup — replaced wholesale each run | raw | 0083, 0107 |
calibration_history |
Append-only calibration trend — one row per (owner, scope, day) from the same job; never recomputed or pruned | raw | 0138 |
calibration_combo_pins |
Pinned scope combos for the calibration UI, one row per (owner, scope_ref) | raw | 0107 |
triality_log |
Append-only Triality outcome log — one row per suggestion shown (slot, action_kind/action_ref, the full scores JSON breakdown), stamped once more on opened/staged/dismissed. The learning corpus, not a cache: two requests log two sets of rows. Enums in code; raw SQL only (not in schema.ts); indexed on (owner_id, local_date). confidence (0144) is a TEXT JSON band stamped at show time, reported and never fed back into ranking; NULL = shown before it existed, and readers exclude those rows from confidence aggregates. weights_source (0174) is a TEXT stamp set at show time from the slot's weights: learned (a triality_weights row ranked it), defaults (no row) or defaults_invalid (a row that failed getWeights' checks); NULL = shown before the stamp or weights injected by a test, and readers must leave those rows out of any learned-vs-default split. motivation (0175) is a TEXT stamp of the suggested task's me / us / them label at show time (none = it had none); NULL = shown before the stamp, and readers leave those out of any split. Descriptive only: ranking never reads it |
raw | 0140, 0144, 0174, 0175 |
triality_weights |
Learned Triality weights per (owner_id, slot) PK — weights JSON, sample_count, computed_date. Regenerable rollup replaced wholesale by calibrate_triality; absence means "the default weights", so deleting an owner's rows is the reset switch. Raw SQL only |
raw | 0142 |
classification_log |
Append-only evidence for the parse/classify LLM's per-owner vocabulary — one row per assignment (field, input_text, llm_value, final_value null until corrected, assigned_at/corrected_at). Written by POST /api/tasks/quick, by the wizard's Add settling the echoed classification_claim on POST /api/tasks (born closed when the saved category list, in order, differs from the model's pick), and by memory enrichment; closed by updateTask when the saved categories change, and for motivation when a motivation is sent or a beneficiary set changes it. A Time Block created from a Describe-it claim is logged the same way with entity_kind = 'time_block' (the id column is still called task_id) and closed by updateBlock when its own categories change; the learner reads both kinds as one body of evidence. Never online, never mid-request: a bad afternoon of corrections can't bend a live answer |
raw | 0149, 0173 |
classification_exemplars |
Regenerable few-shot retrieval index the classify prompts read (quick-add and the wizard's preview by frequency, last in the prompt; memory enrichment and the Monday scorer by similarity), rebuilt wholesale per owner by the weekly calibrate_classification job; deleting an owner's rows resets the adaptation without losing the log above |
raw | 0149 |
classification_accuracy |
Held-out measured trend per (owner, field, computed_date) — heldOut/correct/accuracy; accuracy NULL when nothing was measured, never 0 |
raw | 0149 |
possible_outcome_log |
Close-loop wave (ticket 1) — durable evidence for dismissal learning. Append-only, one row per decided possible_tasks suggestion: source_kind, input_text, outcome (dismissed|promoted), decided_at (load-bearing — possible_tasks itself has no decision timestamp), local_date. possible_task_id deliberately NOT a foreign key: the evidence must outlive the suggestion |
raw | 0157 |
possible_dismissal_exemplars |
Regenerable rollup the extraction prompt reads, rebuilt wholesale per owner by the weekly calibrate_dismissals job from settled possible_outcome_log evidence; inert below DISMISSAL_FLOOR = 5 |
raw | 0157 |
possible_dismissal_rate |
One noise-rate reading per (owner, computed_date) — created/decided/dismissed/rate/exemplars/hinted; rate NULL when decided = 0, never 0, so a trend draws a gap rather than a false floor |
raw | 0157 |
plan_loop_runs |
Revision-loop wave — one row per runLoop() invocation (Phase M §A0 authoring loop): stage, passed, escaped, escape_reason, score, iterations. The durable evidence sink agents/loop.ts's own contract promised but never got — its default sink was audit_log, which nothing ever reads back |
raw | 0162 |
plan_revision_log |
Append-only evidence — one row per candidate the loop scored inside a run: run_id (indexed TEXT, deliberately NOT a foreign key — revisions are written during the run, before the run's own row exists), iter, score, reason, defects JSON, critique |
raw | 0162 |
plan_loop_stats |
Measured trend per (owner, stage, computed_date), written weekly by calibrate_plan_loop over its trailing window — a stage with no runs in the week gets no row; avg_iterations/pass_rate allow NULL but the weekly job never writes one, and a NULL is never shown as 0 |
raw | 0162 |
placement_log |
Placement-learning wave — durable, append-only evidence, one row per engine placement: slot_key (the task's level-1 direction name, or (none)), chosen_start_at/chosen_hour, fit (0 = pickGap fell back to the largest gap and truncated — a forced placement that expresses no preference), candidates (the JSON menu of free gaps pickGap weighed). Closed with moved_* when the owner drags the block. block_id/task_id are deliberately NOT foreign keys: the evidence must outlive the auto blocks reflow() recreates on every run |
raw | 0166 |
placement_preferences |
Regenerable rollup, rebuilt wholesale per owner by the weekly calibrate_placement job: PK (owner_id, slot_key), p25_hour/p75_hour = the owner-local hour BAND where corrections landed, corrections, sample_count |
raw | 0166 |
placement_survival |
Measured trend per (owner_id, computed_date) — decided/accepted/corrected, survival_rate NULL when decided = 0 (a gap, never a false 0%), keys_learned, biased |
raw | 0166 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
external_events |
Sync identity map — provider events ↔ local time_blocks | ✓ | 0051 |
outbound_queue |
Local blocks pending push to provider (when CP_SYNC_WRITEBACK=true) |
✓ | 0057 |
sync_state |
Incremental-sync cursors per (owner, provider, account, resource) |
✓ | 0060 |
sync_events |
Append-only change log behind live updates: SSE replays it by Last-Event-ID, and GET /api/sync/changes?since= serves the same rows as JSON for the app shells, which cannot hold an EventSource; owner FK (0098) |
✓ | 0077, 0098 |
| Table | Purpose | ORM | Added |
|---|---|---|---|
scheduled_jobs |
Croner job registry (name, cron, enabled) | ✓ | 0001 |
job_runs |
Execution log per run (status, items created/updated, log text) | ✓ | 0001 |
reminders |
Scheduled reminders — fired exactly once by the 1-min dispatcher | ✓ | 0044 |
push_subscriptions |
Web Push endpoints per owner (VAPID) | ✓ | 0044 |
audit_log |
Append-only mutation log written by every mutating route | ✓ | 0046 |
copy_history |
Time-Sense voice-loop anti-repetition log (NOT in owner export) | raw | 0109 |
Dropped along the way (superseded, not in the live shape above): task_tags (0041),
plan_outcomes (0095), pattern_prefs/category_prefs (0097), daily_summaries/day_stars (0096).
Column-level shape of every live table (key columns, not every column), grouped by concern. «PK» marks the primary
key (composite where multiple columns are listed); «no FK» marks a reference the database does
not enforce.
classDiagram
direction LR
class owners {
+TEXT id «PK uuidv7»
name, email, role
external_subject, username, password_hash
backup_email, session_epoch
}
class scheduling_prefs {
+TEXT id «PK»
owner_id «FK 1:1»
timezone, capacity_hours_per_week
trust_mode, fill_order, planning_mode
planning/recurrence_horizon_days
feature flags, model overrides, view_prefs
}
class tasks {
+TEXT id «PK»
owner_id, parent_id «FK»
title, status, priority, subtype
due_at, scheduled_for, defer_until
estimated_minutes, actual_minutes
recurrence_rule/_parent_id/_exdates
motivation, completion_score, reaction_at
version, field_ts
}
class time_blocks {
+TEXT id «PK»
owner_id «NOT NULL», task_id, parent_block_id, category_id «FK»
title, start_at, end_at, all_day
block_kind, block_type, status
is_locked, is_auto, color_token
responsible_pinned, beneficiaries_pinned
recurrence_rule/_parent_id, auto_run_id
version, field_ts
}
class time_entries {
+TEXT id «PK»
task_id, owner_id, block_id «FK»
started_at, ended_at «NULL = running»
}
class task_dependencies {
+«PK» task_id, depends_on_id «FK»
}
class possible_tasks {
+TEXT id «PK»
proposed_owner_id, promoted_task_id «FK»
raw_title, confidence, status
proposed_* intent columns
}
class flight_plan {
+«PK» owner_id, task_id «FK»
selected, position
}
class tags {
+TEXT id «PK»
owner_id «FK»
name «uq per owner»
kind: direction|intention|category
muted, is_base
}
class entity_tags {
+«PK» entity_kind, entity_id, tag_id
level, created_at
«entity_id: NO FK — app-enforced»
}
class entity_categories {
+«PK» entity_kind, entity_id, category_tag_id
position 0..2 «stripe order»
«entity_id: NO FK — app-enforced»
}
class tag_prefs {
+TEXT id «PK» — uq(owner, tag)
kind, color_token, default_minutes
auto_fill, reminder_offsets, icon_shape, show_open_time
}
class tag_directions { +«PK» intention_tag_id, direction_tag_id }
class tag_categories { +«PK» intention_tag_id, category_tag_id }
class category_direction_optouts { +«PK» category_tag_id, direction_tag_id }
class patterns {
+TEXT id «PK»
owner_id, type_id «FK»
name, tag, recurrence_rule, color_token
}
class pattern_types {
+TEXT id «PK»
name, icon_shape, is_builtin, catch_all
}
class pattern_tasks { +«PK» pattern_id, task_id : position }
class pattern_blocks {
+TEXT id «PK»
owner_id, direction_tag_id «FK»
day_type, start_time, end_time
}
class pattern_day_optouts { +«PK» owner_id, pattern_id, date }
class day_types { +TEXT id «PK» : key «uq per owner», name, is_builtin }
class day_of_week_map { +«PK» owner_id, dow : day_type_key }
class day_assignments { +«PK» owner_id, date : day_type_key }
class block_templates { +TEXT id «PK» : name, tag, default_minutes, tasks JSON }
class objectives {
+TEXT id «PK»
owner_id, parent_id, category_id, source_task_id
level, horizon, status, cadence
progress_cache, archived_at, purge_after, color_token
}
class objective_tasks { +«PK» objective_id, task_id : linked, version }
class reflections {
+«PK» owner_id, kind, period_key, position
body, done_at, source_kind/_ref, category_id
}
class life_priorities {
+TEXT id «PK» — uq(owner, category)
satisfaction, importance, target_hours_per_week
}
class balance_week_log { +TEXT id «PK» — uq(owner, week_start, category) : target_hours, scheduled_minutes, timed_minutes }
class daily_plan { +«PK» owner_id, date, task_id : position, source, outcome, block_id }
class daily_recap { +«PK» owner_id, date : prose, stats, star_*, total, frozen_at }
class plan_hints { +TEXT id «PK» : date, kind, tag, time }
class replan_suggestions { +owner_id «PK» : date, reason, at_risk }
class planning_sessions { +TEXT id «PK» : entry_branch, status, llm_used, transcript }
class plan_loop_runs { +TEXT id «PK» : stage, passed, escaped, escape_reason, score, iterations }
class plan_revision_log { +TEXT id «PK» : run_id «no FK», stage, iter, score, reason, defects, critique }
class plan_loop_stats { +«PK» owner_id, stage, computed_date : runs, passed, first_try_pass, avg_iterations, pass_rate }
class estimate_calibration { +«PK» owner_id, scope_kind, scope_ref : bias_ratio, p25/p50/p75 }
class calibration_history { +«PK» owner_id, scope_kind, scope_ref, computed_date }
class calibration_combo_pins { +«PK» owner_id, scope_ref }
class classification_log { +TEXT id «PK» : task_id, entity_kind, field, input_text, llm_value, final_value, assigned_at, corrected_at }
class classification_exemplars { +«PK» owner_id, field, log_id : llm_value, correct_value, embedding }
class classification_accuracy { +«PK» owner_id, field, computed_date : held_out, correct, accuracy }
class triality_log { +TEXT id «PK» : slot, action_kind/_ref, scores, outcome, confidence, weights_source, motivation }
class triality_weights { +«PK» owner_id, slot : weights, sample_count }
class possible_outcome_log { +TEXT id «PK» : possible_task_id «no FK», source_kind, input_text, outcome, decided_at, local_date }
class possible_dismissal_exemplars { +«PK» owner_id, log_id : input_text, source_kind, embedding }
class possible_dismissal_rate { +«PK» owner_id, computed_date : created, decided, dismissed, rate, exemplars, hinted }
class connection_tokens { +TEXT id «PK» : provider, account, ciphertext, status }
class connection_prefs { +TEXT id «PK» : provider, account, color_token, texture }
class external_events { +TEXT id «PK» : provider, account, external_id, etag, direction }
class outbound_queue { +TEXT id «PK» : provider, account }
class sync_state { +TEXT id «PK» : provider, account, resource, cursor }
class sync_events { +INTEGER seq «PK AI» : entity, entity_id, version, event, payload }
class reminders { +TEXT id «PK» : fire_at, status, source «task XOR block» }
class push_subscriptions { +TEXT id «PK» : endpoint «uq», p256dh, auth }
class audit_log { +TEXT id «PK» : actor, action, entity, at }
class copy_history { +TEXT id «PK» : surface, signature }
class scheduled_jobs { +TEXT id «PK» : name «uq», kind, cron, enabled }
class job_runs { +TEXT id «PK» : status, items_created/_updated, log }
class api_keys { +TEXT id «PK» : prefix, key_hash, scopes, revoked_at }
class auth_states { +TEXT id «PK» : state «uq», code_verifier, nonce }
class oauth_states { +TEXT id «PK» : state «uq», code_verifier }
class password_reset_tokens { +TEXT id «PK» : token_hash, expires_at, used_at }
class possible_task_tags { +possible_task_id, tag_id «FK» }
class app_config { +key «PK» : value, updated_at }
class people {
+TEXT id «PK»
owner_id «FK tenancy», account_id «FK to owners, NULL unless this person has a login»
display_name «uq per owner», confirmed, source
}
class person_emails { +«PK» owner_id, email : person_id «FK» }
class entity_people {
+«PK» entity_kind, entity_id, person_id, role
role: responsible|beneficiary, source, position
«entity_id: NO FK — app-enforced»
}
class possible_task_people { +«PK» possible_task_id, person_id, role : source, confidence }
class signup_codes { +TEXT id «PK» : code_hash «uq», created_by/used_by «FK SET NULL», expires_at, used_at, revoked_at }
class placement_log { +TEXT id «PK» : block_id/task_id «no FK», slot_key, chosen_start_at/_hour, fit, candidates, moved_start_at/_hour, local_date }
class placement_preferences { +«PK» owner_id, slot_key : p25_hour, p75_hour, corrections, sample_count }
class placement_survival { +«PK» owner_id, computed_date : decided, accepted, corrected, survival_rate, keys_learned, biased }
owners "1" -- "1" scheduling_prefs : configures
owners "1" --o "0..*" tasks : owns
owners "1" --o "0..*" time_blocks : owns
owners "1" --o "0..*" tags : owns
owners "1" --o "0..*" patterns : owns
owners "1" --o "0..*" pattern_types
owners "1" --o "0..*" pattern_blocks
owners "1" --o "0..*" objectives : owns
owners "1" --o "0..*" reflections
owners "1" --o "0..*" life_priorities : audits
owners "1" --o "0..*" balance_week_log
owners "1" --o "0..*" possible_tasks : proposed_for
owners "1" --o "0..*" possible_outcome_log
owners "1" --o "0..*" possible_dismissal_exemplars
owners "1" --o "0..*" possible_dismissal_rate
owners "1" --o "0..*" reminders
owners "1" --o "0..*" push_subscriptions
owners "1" --o "0..*" api_keys
owners "1" --o "0..*" password_reset_tokens
owners "1" --o "0..*" connection_tokens
owners "1" --o "0..*" connection_prefs
owners "1" --o "0..*" oauth_states
owners "1" --o "0..*" sync_state
owners "1" --o "0..*" sync_events
owners "1" --o "0..*" audit_log
owners "1" --o "0..*" copy_history
owners "1" --o "0..*" day_types
owners "1" --o "0..*" day_of_week_map
owners "1" --o "0..*" day_assignments
owners "1" --o "0..*" block_templates
owners "1" --o "0..*" planning_sessions
owners "1" --o "0..*" plan_loop_runs
owners "1" --o "0..*" plan_revision_log
owners "1" --o "0..*" plan_loop_stats
owners "1" --o "0..*" plan_hints
owners "1" --o "0..1" replan_suggestions
owners "1" --o "0..*" daily_plan
owners "1" --o "0..*" daily_recap
owners "1" --o "0..*" estimate_calibration
owners "1" --o "0..*" calibration_history
owners "1" --o "0..*" calibration_combo_pins
owners "1" --o "0..*" classification_log
owners "1" --o "0..*" classification_exemplars
owners "1" --o "0..*" classification_accuracy
owners "1" --o "0..*" triality_log
owners "1" --o "0..*" triality_weights
owners "1" --o "0..*" flight_plan
owners "1" --o "0..*" tag_prefs
owners "1" --o "0..*" pattern_day_optouts
owners "1" --o "0..*" outbound_queue
owners "1" --o "0..*" external_events
owners "1" --o "0..*" people : directory
owners "1" --o "0..*" person_emails
owners "1" --o "0..*" signup_codes : created_by, used_by «SET NULL»
owners "1" --o "0..*" placement_log
owners "1" --o "0..*" placement_preferences
owners "1" --o "0..*" placement_survival
tasks "1" --o "0..*" tasks : subtask of
tasks "1" --o "0..*" time_blocks : scheduled as
tasks "1" --o "0..*" time_entries : worked in
tasks "1" --o "0..*" task_dependencies : blocked by
tasks "1" --o "0..*" daily_plan : ordered in
tasks "1" --o "0..*" flight_plan : staged in
tasks "1" --o "0..*" pattern_tasks
tasks "1" --o "0..*" objective_tasks
possible_tasks "0..1" ..> "1" tasks : promoted to
possible_tasks "1" --o "0..*" possible_task_tags
possible_tasks "1" --o "0..*" possible_task_people
time_blocks "1" --o "0..*" time_blocks : Plan container
time_blocks "1" --o "0..*" time_entries
time_blocks "1" --o "0..*" external_events : mirrored by
time_blocks "1" --o "0..*" outbound_queue : pending push
time_blocks "1" --o "0..*" reminders
time_blocks "0..*" --> "0..1" tags : category_id «frozen color»
tags "1" --o "0..*" entity_tags : labels
tags "1" --o "0..*" entity_categories : categorizes
tags "1" --o "0..*" tag_prefs
tags "1" --o "0..*" tag_directions
tags "1" --o "0..*" tag_categories
tags "1" --o "0..*" category_direction_optouts
tags "1" --o "0..*" pattern_blocks : direction window
tags "0..1" <-- "0..*" objectives : category_id
tags "0..1" <-- "0..*" reflections : category_id
tags "1" --o "0..*" possible_task_tags
entity_tags ..> tasks : entity_kind='task' «no FK»
entity_tags ..> time_blocks : entity_kind='time_block' «no FK»
entity_tags ..> patterns : entity_kind='routine' «no FK»
entity_categories ..> tasks : «no FK»
entity_categories ..> time_blocks : «no FK»
people "1" --o "0..*" person_emails : matched by address
people "1" --o "0..*" entity_people
people "1" --o "0..*" possible_task_people
people "0..*" --> "0..1" owners : account_id «the login this person is»
entity_people ..> tasks : entity_kind='task' «no FK»
entity_people ..> time_blocks : entity_kind='time_block' «no FK»
pattern_types "1" --o "0..*" patterns : typed by
patterns "1" --o "0..*" pattern_tasks
patterns "1" --o "0..*" pattern_day_optouts
day_types ..> day_of_week_map : key
day_types ..> day_assignments : key
day_types ..> pattern_blocks : day_type
objectives "1" --o "0..*" objectives : parent of «live tree»
objectives "1" --o "0..*" objective_tasks : progressed by
tasks "0..1" <.. "0..*" objectives : source_task_id
daily_plan "0..*" --> "0..1" time_blocks : block_id
scheduled_jobs "1" --o "0..*" job_runs : logged as
possible_outcome_log "0..1" ..> "0..1" possible_tasks : possible_task_id «no FK, evidence outlives the row»
possible_outcome_log ..> possible_dismissal_exemplars : weekly calibration
plan_revision_log "0..*" ..> "0..1" plan_loop_runs : run_id «no FK, written before the run row exists»
plan_loop_runs ..> plan_loop_stats : weekly calibration
classification_log ..> classification_exemplars : weekly calibration
balance_week_log ..> life_priorities : category «string match, no FK»
placement_log ..> time_blocks : block_id «no FK, evidence outlives the block»
placement_log ..> placement_preferences : weekly calibration
placement_log ..> placement_survival : weekly measurement
Three things worth flagging in the model:
entity_tags/entity_categories/entity_peopleare polymorphic with no FK onentity_id— the dotted edges above are app-enforced byservices/tags.ts(removeEntityTagsclears all three on every hard delete), not the DB.time_blocks.tagandpatterns.tagremain denormalized top-level mirrors.ownersis the tenant root — nearly every table hangs off it, but cascade behavior is inconsistent (someON DELETE CASCADE, some plain FK), so owner deletion is an explicit ordered purge rather than a bareDELETE:deleteOwner()(lib/owners.ts, behindDELETE /api/accounts/:id) runs one transaction with deferred FK checks, clears the owner's portable tables, then the non-portable ones, then theownersrow, and refuses to remove the last operator.- 29 tables live only in raw SQL, outside
schema.ts(marked "raw" in §5.2) — the whole life layer (objectives,reflections), the AI-loop tables, and every evidence/rollup/measurement trio in the Learning loops section (calibration, triality, classification, dismissal, plan-loop, placement) plusbalance_week_log. They're invisible to Drizzle queries and to theschema-paritytest that guards drift — deliberately, per each table's own migration comment: none of them are mirrored inschema.ts.
Two clocks drive the day:
| Clock | What it does | When it runs |
|---|---|---|
| Slow (LLM) | Orders tasks into a frozen daily_plan with a "why now" rationale per block |
Once per (owner, day), author-once: on the day's first POST /api/auto/run, or forced on demand by POST /api/plan/reauthor. The LLM loop runs only when planning_mode='smart' and Ollama is ready; otherwise a deterministic order is written. No nightly job authors the plan |
| Fast (deterministic) | Re-packs movable auto-blocks from a snapshot of the day whenever the obstacle set shifts | Debounced reactive trigger on a scheduling-relevant task edit / calendar sync / missed block |
Snapshot-based reflow (Phase AE — engine/auto-defense.ts). The reactive reflow's re-place set is exactly the movable auto blocks it snapshotted — the clear set equals the re-place set by construction, so re-placing after a task edit can never delete a block it isn't about to rebuild. (This fixed the AE bug where editing one placed task's start time wiped every other flight-plan-placed task: the old code cleared all agent blocks but re-placed only a subset.) daily_plan is an ordering hint only; it no longer gates membership. Related AE invariants: status parity (every placement path flips a task open → scheduled and reverts it when its last active block is cleared/swept; engine placements mirror scheduled_for but never task_time, which stays user-stated intent), a not-before-now clamp (on the owner-local current day engine-chosen slots never start before now — a late reflow packs the remaining day), and locked-block guards (no engine path — reflow clear, reschedule, nightly review, missed sweep — may delete/relabel/relocate an is_locked=1 pin). The replan trigger is gated to fire only on a scheduling-relevant column change (updateTouchesSchedule), so a title/notes/tag edit never churns the day.
Anti-thrash rules: never move an in-progress block, never move user-locked or recently-moved blocks (last_moved_at), debounce bursts into one pass. On a big disruption the fast clock surfaces a single "Re-plan with AI?" nudge — never auto-calls the LLM.
Leftover fill order (scheduling_prefs.fill_order). Applies to Pass 2b — the workday fallback fill — and only when the day has no authored daily_plan, since an authored day already encodes its sequence. 'priority' places P1 first then by deadline; 'overdue' (the authoring default) places soonest-due first then by priority. A third value 'new_today' (§1.3) gives the Tasks tab's New Today set the earliest slots with each half still deadline-first, wired into both engine/autoplan.ts (orderByNewToday) and agents/plan.ts (deterministicIndices) so it is not inert on deliberately-planned days.
Review job (review_day, nightly 01:00): scans for planned task-blocks whose end time passed un-started (locked pins excluded — a deliberate pin is not "missed work"). Every priority now auto-defers with a +1/3/7-day backoff by prior defer_count — scheduled_for moves out, status returns to open, and the stale block is consumed. The old hybrid that parked priority ≤ 2 tasks as status='missed' for manual review was retired by owner ruling on 2026-07-20 (the Missed review surface only arrived in #409, so years of P1/P2 tasks silently starved Fill/Auto in a black hole); the same run also sweeps any still-stuck status='missed' one-offs back to open. The daily_plan.outcome stamp keeps the priority split ('missed' for P≤2, 'deferred' for P≥3) before the stale block is dropped, so the nightly aggregates can still distinguish an important miss from a routine slip (the N4 lossy-slip fix). Owners on trust_mode='hands_off' get each deferred/revived task booked into its new day via placeStartAnchoredTask (best-effort — a placement failure never fails the sweep); Suggest-mode owners get it back as open with a dated scheduled_for and nothing placed. Same engine behind the top-bar sweep button (POST /api/review/sweep).
Companion Planning exposes an Apollo Server 5 GraphQL surface at POST /api/graphql, mounted via @as-integrations/koa alongside the REST API — REST remains authoritative and untouched. The schema is schema-first (hand-authored SDL) and follows CI-Server-compatible conventions: Node / Relay-style Connection/Edge/PageInfo pagination, and DateTime / JSON custom scalars. Introspection is on; a custom complexity-limiter plugin (not graphql-query-complexity) guards query cost (GRAPHQL_MAX_COMPLEXITY, default 1000), and graphql is pinned to 16.x. The whole surface sits under requireAuth (a POST maps to the planning:write scope, so a read-only viewer session or key cannot call it), and the resolver context reads currentOwnerId() from the same AsyncLocalStorage the REST routes use. Decision 3: every resolver calls the existing services/*.ts functions — it never re-derives owner-scoping. A shared service layer (services/tasks.ts, blocks.ts, flight-plan.ts, patterns.ts, objectives.ts, notifications.ts, prefs.ts, jobs.ts, engine-ops.ts, alongside the pre-existing services/tags.ts) gives REST and GraphQL one owner-scoped implementation per resource area.
The full CRUD surface is now live (W1–W13 + W-sandbox + W-close):
- TagType (direction / intention / category hierarchy connections +
entitiesreverse edge) - TaskType, TimeBlockType, EntityTagType (forward
Taggable.tagson Task + TimeBlock) - FlightPlanEntryType; Pattern / PatternKind / PatternBlock / PatternPref; DayType / TagPref
- Objective / Reflection / DayStars / DailyRecap (unblocks
TaskType.objective); PossibleTask / BlockTemplate - Reminder / PushSubscription; SchedulingPrefs; ScheduledJob / JobRun (admin-scope gated, system-global)
- mutation-only engine ops (autoplan / find-slot / instruct / clear-planner)
- since the CRUD wave: Person (
people/person/accountRoster, plus theresponsible/beneficiariesedges on Task and TimeBlock, 0151) and Triality (trialityNext/trialityFeedback, flag-gated ontriality_enabled)
A self-hosted, flag-gated GraphiQL sandbox (GET /api/graphql/sandbox; on outside production, and GRAPHQL_SANDBOX=1 opts it in under NODE_ENV=production) ships too. Perf indexes for the tag keyset connections landed in migration 0112. See docs/W-graphql-crud.md.
sequenceDiagram
autonumber
participant Cron
participant Ing as Ingest / Memory Agent
participant MCP as Gmail / GCal MCP
participant Mem as CI-Server memory
participant LLM as Ollama
participant DB as SQLite
Cron->>Ing: ingest_gcal (every 30m) / ingest_gmail (every 15m)
Ing->>DB: read (owner, google, account) sync cursor
Note over Ing,DB: §1.3 — the Gmail cursor is floored:<br/>since = max(watermark, now − email_lookback_days)
Ing->>MCP: fetch deltas since that instant
MCP-->>Ing: events / messages
loop each item
Ing->>LLM: extract action items (quality tier)
LLM-->>Ing: candidate + confidence
Ing->>DB: upsert possible_tasks
end
Note over Cron,Mem: Memory layer (memory_pull_enabled) ─────────
Cron->>Ing: memory_suggest (every 6h)
Note over Ing,Mem: window = now .. now + memory_window_days<br/>(§1.3, default 2) via the shared memoryWindowMs(owner)
Ing->>Mem: pull upcoming graph (read-only)
Mem-->>Ing: events + participants
Ing->>DB: stage possible_tasks (idempotent on source_ref)
Note over Cron,DB: Nightly ──────────────────────────────────
Cron->>DB: reset_routines (00:00) — reopen routine checklists
Cron->>DB: freeze_stars (00:20) — snapshot yesterday's five-star score
Cron->>DB: review_day (01:00) — auto-defer missed tasks (+1/3/7 backoff)
Cron->>DB: calibrate_estimates (01:30) — compute bias ratio (nightly since 0107)
Cron->>LLM: nightly_grooming (02:00) — embedding dedupe + overdue bump + straggler promote
Cron->>DB: snapshot_db (02:00) — point-in-time SQLite backup
Cron->>Mem: push_memory (02:00) — one-way push of completed/habit/reflection events
Cron->>DB: generate_recurrences (02:45) — materialize RRULE instances
Cron->>DB: auto_schedule (03:00) — propose flight-plan candidates + sweep missed blocks
Cron->>DB: purge_objectives (hourly) — reap archived objectives past purge_after
Note over Cron,DB: Weekly ─────────────────────────────────────
Cron->>DB: calibrate_triality (Mon 03:50) — re-tune Triality slot weights
Cron->>DB: calibrate_classification / calibrate_dismissals / calibrate_plan_loop / snapshot_balance / calibrate_placement (Mon 04:10–04:50) — the other weekly per-owner learning passes
Cron->>DB: advance_objectives (Mon 05:00) — stage objective tasks
Note over Cron,DB: A tick that fell while the server was down is not replayed by the cron.<br/>calibrate_estimates and the six Monday learning passes (not advance_objectives)<br/>are made up once, about 60 s after the next start (§7.1)
Call parameters live in one table. Every agent above reaches Ollama through
generate / generateJson / embed in server/src/mcp/ollama.ts, and none of them
states a tier, temperature, or timeout of its own: each names a purpose from
CALL_POLICY in that same file (classify, classify_wide, brainstorm, extract,
plan_generate, plan_judge, plan_narrate, plan_instruct, suggest, narrate,
voice), and the table supplies the three numbers with a comment saying why they are
what they are. Raising every classifier's timeout is therefore one edit, not a grep.
An explicitly passed tier / temperature / timeoutMs still overrides the purpose,
which is how the two genuinely computed sites work — agents/render-copy.ts picks a
per-surface tier and a longer ceiling for quality surfaces, and
services/triality-rationale.ts passes the caller's remaining stage budget. The four
plan_* purposes stay separate entries even where values coincide: the §A0 loop's
"the generator is never the evaluator" guarantee is expressed as a tier split, with the
generator on fast and plan_judge on quality. These are code constants, not
configuration — they were measured against a live model, so they change under a measured
retune, never through an env var.
Failure handling lives in one helper, and availability in one predicate. "Ollama
might be down, degrade gracefully" is the most-repeated rule in the codebase, and it used
to have six implementations — four of them a bare catch {}. It now has one:
llmOrNull(op, fn) in server/src/mcp/ollama.ts returns null for the two failures a
caller can do nothing about — OllamaUnavailable (unreachable, timed out, model not
pulled) and LlmParseError (the reply is not JSON) — logs exactly one warn naming the
op, and re-throws everything else. That last clause is the point: a TypeError in our
own callback is a bug, and the old catch {} blocks turned it into an empty result
indistinguishable from a model that had nothing to say. Each caller still owns its own
fallback — suggestTags returns [], enrichTaskFromMemory returns
{ enriched: false, reason }, renderCopy drops to its rotation pool, ingest stages a
raw fallbackTask for a reply that is not JSON — only the plumbing converges. Ingest also
uses llmAttempt(op, fn), llmOrNull's sibling that says which of the two failures
happened ('unavailable' or 'unparseable'): with Ollama down it stages nothing and
defers the item, because the watermark moves past everything it stages and a stub written
during an outage would never be revisited (agents/ingest.ts; the run log counts these as
deferred).
Two paths are deliberately outside it. The runLoop stage callbacks (generate /
judge / revise in agents/plan.ts) must keep THROWING: the §A0 loop's error contract
(agents/loop.ts) is that a transport failure propagates out to the authoring caller,
which falls back to the deterministic baseline. A null returned into the loop would
flow through check()/judge() as a candidate and silently break the stall→baseline
escape hatch and the call-budget accounting — the loop and its caller ARE the handler
there. And in agents/render-copy.ts the two exits stay distinct: transport failure
breaks straight to the pool, while a gate rejection regenerates once with the reason fed
back.
"Can we call a model?" is answered only by isReady() — status().ready, meaning
reachable and the effective fast/quality models are actually pulled (the embedder is deliberately not part of it; the embedding paths ask embedReady()). The predicate it
replaced, isAvailable() (status().available, "Ollama answered /api/tags"), is a
green light with nothing behind it, and gating on it ran the full generate→judge→revise
loop against installs that could not serve a single call. status().available survives
only on the status object, for the Settings card that legitimately shows reachable and
usable as distinct states.
The probe under that predicate is cached; the model resolution above it is not. Making
isReady() the one predicate put a live /api/tags fetch on the day-authoring and
NL-instruct hot paths as well as the Settings poll, so status() now caches the probe
(reachability + installed set) for PROBE_TTL_MS = 30s — a code constant, not
configuration. Everything else status() returns is recomputed on every call, and that
split is load-bearing: a model chosen in Settings is a DB write that must take effect on
the very next request, so caching the resolved names would make a saved choice look
like it never applied (ollama.test.ts pins this with an override written between two
cached calls). Two bypasses: NODE_ENV === 'test' disables the cache outright, for the
same reason the auto-select guard beside it does — no suite may depend on which test
probed first — and status(owner, { fresh: true }) forces a live read, reached by
GET /api/llm/status?fresh=1. That endpoint is otherwise diagnostic-only (the UI reads
the richer GET /api/integrations, which takes the cached read); its one live caller is
Settings → "Re-check", the escape hatch for an Ollama that came up after the app, which a
TTL alone cannot cover. Settings renders Ollama health in exactly one place — the Models
card, beside the selects — with the same three states (unavailable / reachable-but-unusable
/ ready) it always had.
What a model is allowed to write is decided in two shared modules, not per agent. A classifier's answer reaches the user's own taxonomy, so the rule that filters it is the one place a bug would be least visible and most damaging — a suggester that can mint new directions, categories or tags is not a suggester, it is a second author of the user's data.
agents/constrain.tsowns the never-invent rule:constrainToVocabulary(raw, vocabulary, max)andconstrainOne(raw, vocabulary)keep only names the owner actually has, in the vocabulary's casing (never the model's), deduped case-insensitively and capped. Quick-add's overlay, the Auto-tag sweep and the memory enrichment pass each used to carry their own copy, and the copies had already drifted — one deduped on the canonical name, the other on its lowercase, equivalent only while canonical names stay unique case-insensitively. Matching is case-insensitive on purpose: a model answering"finance"for a"Finance"tag is right, and dropping it silently was the older behaviour (tags-suggest.test.tspins this, including the downstream invariant that a canonical name survivesmergeTags' case-sensitive filter). The vocabulary itself is never re-derived here — it comes fromsuggestibleVocabulary(services/tags.ts), which owns the muted rule for every suggester.agents/rubric.tsowns the prompt text every classifier shares — priority, duration, time-of-day, motivation, complexity, and the direction definitions. Each exists because a bare label under-specifies the axis for a small local model:"priority 1-5"is not a rubric, so the model guesses high and the axis degrades into noise. Kept in one module so quick-add, the Auto-tag sweep and the memory pass cannot drift into three definitions of the same word.
Two rules that layer carries are easy to undo by accident. A shared definition must not name
the caller's field: COMPLEXITY_DEFINITION is field-neutral, and each caller supplies its own
leading sentence — quick-add's COMPLEXITY_RUBRIC prepends "Set isComplex true when …", while
agents/subtasks.ts prepends "isBreakdownUseful is true when …", because that model ignores
instructions no schema field backs and a borrowed field name would disarm its refusal gate. And
the deterministic gate must not import from the generator: agents/voice-gate.ts owns
BANNED_PHRASES and derives the prompt text render-copy.ts interpolates, so the list the gate
enforces and the list the model is shown cannot diverge — they had, by two words, which cost a
regeneration every time a model used one of them.
Prompt text is behaviour. Every string in those modules is sent to a model, so an edit is a
behaviour change that needs re-measuring against a live model — and measuring means sampling,
not one before/after pair: llama3.2 at temperature: 0.1 is not deterministic, and
incidental fields drift between runs of a byte-identical prompt. Compare only the
rubric-governed verdict, over N≥5 runs, and assert the completion is non-empty first — a
thinking-capable model returns "" under format:"json", which reads exactly like
"unchanged". docs/complexity-rubric-measurement-2026-08.md is the worked example.
Catch-up after downtime. croner never re-fires a tick that fell while the process was not running: at start it works out the next tick from the wall clock and keeps no memory of the ones that went by. A server that was down through the Monday block (03:50 to 04:50) therefore skipped that week's learning passes, and a correction made the day before was not applied until the Monday after. jobs/catch-up.ts makes up the kinds that are safe to run late. boot.ts calls scheduleCatchUp() right after startCronJobs(); it arms one unref'd timer that fires 60 s later (CATCH_UP_DELAY_MS), once the listener is up and the Ollama probe has settled, so a crash loop that restarts faster than that never gets there. cancelCatchUp() runs in the SIGTERM/SIGINT handler ahead of server.close: a pass that has not started never starts, and one under way starts no further kind. The kind already running is cut with the process like any other job, and the next boot counts it as interrupted. The paths that lose a tick are a restart or redeploy across it and a self-hosted machine that is off at it. Closing a desktop or phone app loses none, because those are clients of the server (desktop/README.md).
Which kinds. An explicit allow-list, CATCH_UP_KINDS, in cron order: calibrate_estimates (daily 01:30), then the Monday block calibrate_triality, calibrate_classification, calibrate_dismissals, calibrate_plan_loop, snapshot_balance, calibrate_placement. Nothing is derived from the cron period, because kinds with the same period differ in whether a late run is safe, and a derived rule would catch up every new kind by default. A kind belongs when a run at 09:00 produces what its tick would have (it rebuilds from durable logs over a window anchored on now, or its writes are first-wins or keyed by date); it writes only its own rollup and evidence tables and calls no third party; it is owner-scoped; and with the model down it leaves a gap, never a destructive downgrade. That last condition is why calibrate_dismissals keeps the vectors it already stored, matched by log_id as calibrate_classification does, so an embedder outage costs only the rows added since instead of blanking the index. Every other seeded kind has a one-line reason in NOT_CAUGHT_UP, and a test checks that each seeded kind is on one side or the other, so a kind added by a migration cannot ship unclassified (OWNER_SCOPED_KINDS carries a pointer). The time-of-day kinds are out because a late run is not the same as a run on time (reset_routines reopens what was ticked off that morning, review_day defers this morning's blocks, auto_schedule skips and re-stages today's ended blocks, nightly_grooming moves overdue priorities up again, advance_objectives stamps the run time and so delays the next Monday).
How it decides. One kind at a time, in that order, so the stagger the migrations chose for the model kinds holds. For each kind it reads the scheduled_jobs row (enabled = 1, the predicate startCronJobs uses, so disabling a job disables its catch-up) and asks missedTick(cron, last_run_at, now): did a tick fall after last_run_at? The tick is the latest one strictly before now, in server-local time. last_run_at is the evidence. Null counts as overdue, because scheduled_jobs has no created_at and reading null as current would starve a host that is never up at the tick; a value more than 5 minutes ahead of now (CATCH_UP_CLOCK_SKEW_MS) counts as no record. A fresh install therefore runs the seven once, over empty data (bar any within 10 minutes of their next tick), and the stamp that run writes repairs a future-dated one. Then three guards, in this order: a kind whose next real tick is 10 minutes away or less (CATCH_UP_IMMINENT_MS) is left to it; a kind with two or more interrupted — server restarted rows since the missed tick (CATCH_UP_MAX_INTERRUPTED, counted from job_runs because an interrupted run never reaches last_run_at) waits for its next tick, so a job that kills the process is not retried at every boot; a kind whose scheduled run is going (isScheduledJobBusy) is skipped without the lock's refusal warning. What passes goes through runScheduledJob(kind, { logPrefix }), the same path the cron takes, so the per-owner fan-out, the tick lock, job_runs rows, SSE and audit are reused, and each kind runs once however many ticks it missed (snapshot_balance after several weeks away freezes only the latest closed week). The Jobs view needs no change: the run's log starts with Caught up (missed 2026-11-02 04:10) — or, for a null or future-dated stamp, Caught up (no earlier run recorded) — . Nothing here can reach boot: a failure is logged as Catch-up failed and the pass moves on. A boot with nothing overdue logs nothing above debug level; each run it starts is logged at info.
What last_run_at cannot say. It is one value per kind, written only by runJobInner when a run finishes, whichever owner ran it and whether the run was scheduled or manual, done or errored; never for a run that was skipped or interrupted. job_runs has no owner column, and adding one would need a migration the Postgres schema (which stops at 0147, server/db/postgres/README.md) cannot take, so the limits are recorded here rather than fixed:
- A manual run, or Run all, by one owner after the missed tick makes the kind look current for every owner. A scheduled fan-out that stopped after owner A hides owner B in the same way. Both fall back to the old behaviour: no run until the next tick, never an extra one.
- A run that errored counts as attempted and is not retried, as a failed tick is not retried today; a bug in a job cannot make it run again at every boot.
calibrate_placementafter downtime reads a block whose slot passed while the server was down as accepted, as the Monday tick does after an away week.- A host that is off every night still loses the whole nightly stack (
reset_routines,freeze_stars,review_day,generate_recurrences,auto_schedule,nightly_grooming,snapshot_db,push_memory). Making those late-safe needs job code that takes the date it is making up, which is a separate change.generate_recurrencesandsnapshot_dbare the first candidates to join the list.
Why there is no periodic sweep. After a machine sleep croner already fires one late tick per kind. Its timer is capped at 30 s and, each time it wakes, it compares the wall clock with the target tick, so a process that survives a sleep runs each kind once within about 30 s of waking and does not replay the ones in between (checked on croner 10.0.1 with a patched Date jumped 3 h 10 min across three hourly ticks: one fire about 20 s later, no replay; a real OS suspend was not exercised). Only a process that was not running loses a tick, and boot covers that. A sweep would also run jobs in the middle of a session and could not tell a miss from a tick that was refused or errored. That late fire also runs the time-of-day kinds at wake time; it exists today, and catch-up neither causes nor changes it.
| Kind | Cron | Purpose |
|---|---|---|
fire_reminders |
every 1m | Fire due reminders exactly once (SSE + Web Push) |
sync_calendar |
every 15m | Google Calendar sync — inbound meeting mirror + placement always; outbound push of local blocks only with CP_SYNC_WRITEBACK=true, and CP_SYNC_DISABLED=true stops both (Phase J) |
ingest_gmail |
every 15m | Extract possible-task candidates from Gmail messages (review-gated — they wait for approval, see services/review-gate.ts). The since watermark is floored at now − email_lookback_days (§1.3), so a first ingest or a post-outage run can't replay months of mail through the extraction LLM. In live mode the job runs only when CP_GOOGLE_ENABLE_GMAIL=true; a run extracts at most 50 messages (oldest first), and an Ollama outage defers the batch with the watermark held instead of staging stubs |
ingest_gcal |
every 30m | Extract possible-task candidates from Calendar events |
memory_suggest |
every 6h | Pull the upcoming CI-Server memory graph, stage events as suggestions (AA1; no-op unless memory_pull_enabled + connector creds). Window is memory_window_days (§1.3, default 2), shared with the daily brief via memoryWindowMs(owner) |
tag_tasks |
hourly | Auto-tag every untagged open task for the owner |
purge_objectives |
hourly | Reap archived objectives past their purge_after deadline — the ONLY path to permanent removal (0130). Global, not per-owner — deliberately absent from OWNER_SCOPED_KINDS, since a purge deadline is an absolute wall-clock instant, not an owner-local one |
reset_routines |
daily 00:00 | Reopen completed Routine checklist tasks for the new day (AC) |
freeze_stars |
daily 00:20 | Snapshot yesterday's five-star score into daily_recap (owner-tz day; idempotent, first-freeze-wins on frozen_at) |
review_day |
daily 01:00 | Auto-defer missed planned tasks with the +1/3/7-day backoff (every priority, since the 2026-07-20 ruling — see §6), revive stuck status='missed' rows, and stamp daily_plan.outcome before dropping the stale block |
calibrate_estimates |
daily 01:30 | Compute personal estimate bias ratio — moved here from Sun 04:00 by 0107 (UPDATE scheduled_jobs SET cron = '30 1 * * *', applied only to rows still on the old weekly cron so an operator-customized one is never clobbered) |
promote_possibles |
— (manual only) | Unconditionally promote any pending inbox stragglers into Tasks, then hands-off auto-block — cron row retired by 0118 (AF-3: producers auto-promote at ingestion; nightly_grooming subsumes the sweep) |
nightly_grooming |
daily 02:00 | Per owner: embedding-dedupe pending possibles · bump overdue open-task priority · unconditionally promote pending stragglers · hands-off auto-block the newly promoted |
snapshot_db |
daily 02:00 | Write a point-in-time SQLite backup to <DB dir>/backups/companion-<timestamp>.db and keep the newest SNAPSHOT_RETAIN (default 7), see §14 |
push_memory |
daily 02:00 | One-way push of completed tasks / habit occurrences / reflections to CI-Server as POST /v1/events (AA4; gated by memory_sync_enabled + per-entity scope toggles; watermarked by memory_last_synced_at) |
generate_recurrences |
daily 02:45 | Materialize recurring Task/Habit instances; flag missed habits |
auto_schedule |
daily 03:00 | Proposes open-task candidates into the flight-plan (fillFlightPlanCandidates) and sweeps blocks whose planned time passed un-started (sweepMissedBlocks → mark skipped, revert the task to open, re-stage at the END of the plan), then requestReplan('missed'). It does not pack the backlog — Auto stopped doing that in Phase G; placement is user-triggered via POST /api/auto/run |
prune_sync_events |
daily 03:00 | Delete sync_events rows older than 30 days (all owners in one global pass), and log the audit_log row count, which is deliberately never pruned |
enrich_tasks |
daily 03:20 | Per owner: fill blank description / direction / categories on thin tasks from Companion Memory (fill-blanks-only; no-op when memory is off) (0136) |
calibrate_triality |
Mon 03:50 | Re-tune the per-slot Triality weights from 90 days of triality_log outcomes — batch, each weight clamped to ½×–2× of its default and then renormalized so the slot's weights still sum to the default total, and inert below 20 decided rows for a slot. Owner-scoped (in OWNER_SCOPED_KINDS): weights are per-owner by construction, and pooling one owner's feedback into another's ranking is ruled out absolutely (0143) |
calibrate_classification |
Mon 04:10 | Weekly per-owner rebuild of classification_exemplars (the parse/classify LLM's few-shot retrieval index) from classification_log corrections, plus a held-out classification_accuracy measurement. Owner-scoped: corrections are per-owner by construction. The index it writes is what quick-add and the wizard's preview (for the models the hint was measured on, agents/quickadd-hint-models.ts), memory enrichment and the job's own held-out scorer read, so a correction a person makes reaches no prompt before the next run. Runs after calibrate_triality so the two weekly learning passes don't compete for one GPU (0149, schedule seeded by 0150) |
calibrate_dismissals |
Mon 04:20 | Close-loop wave (ticket 3): rebuild possible_dismissal_exemplars wholesale from settled possible_outcome_log evidence (inert below DISMISSAL_FLOOR = 5 settled dismissals) and append one possible_dismissal_rate reading, whose rate the run's log line also states (noise rate 42% (21 of 50 decided dismissed, last 7 days), or not measured when nothing was decided). Owner-scoped: dismissal evidence is per-owner by construction, never pooled. Runs after calibrate_classification (Mon 04:10) so the weekly per-owner learning passes don't compete for one GPU (0158) |
calibrate_plan_loop |
Mon 04:30 | Revision-loop wave (ticket 3): pure-SQL weekly aggregate over plan_loop_runs into one plan_loop_stats row per (owner, stage) — pass rate, first-try pass rate, escapes, average iterations. Its log line also names the (up to 3) rules the checker flagged most often in the window, read from plan_revision_log.defects — that table's only reader; the list is not stored. Makes NO model calls, but stays inside the weekly learning block so the cron schedule reads as one unit (0163) |
snapshot_balance |
Mon 04:40 | Balance-targets wave (ticket 6): freeze each life dimension's target/scheduled/timed hours for the week just closed into balance_week_log. INSERT OR IGNORE, first-freeze-wins — a missed week can never be recomputed later. Its only reader is GET /api/priorities/history (balanceHistory), which feeds the details drawer's "met target N of last M weeks" readout; the bias, prompts and the engine never see this table (0165) |
calibrate_placement |
Mon 04:50 | Placement-learning wave (ticket 4): weekly per-owner rebuild of placement_preferences — the owner-local hour band (p25–p75, at most 6 hours wide) each slot key actually ends up in — from 90 days of placement_log rows, plus one placement_survival reading. A key needs CORRECTION_FLOOR = 5 corrections before it gets a band, and at most 50 keys are kept. An unmoved row counts as accepted only once its slot has passed and its block still stands: a block that is gone, or ended skipped, cancelled or missed, is not evidence either way (a move the owner made is graded first and stands). Makes NO model calls. Owner-scoped (in OWNER_SCOPED_KINDS): one owner's corrections never bias another's scheduler. The Auto engine's placementScorer then biases gap choice toward the band. Last of the Monday learning passes (0167) |
advance_objectives |
Mon 05:00 | Stage tasks for active objectives into flight-plan |
| View | Route | Key components |
|---|---|---|
| Today | / |
Day-stars tally · day header (date stepper; Due button opening a popup; Today's Brief · Focus bullseye · Note, each a popover; capacity bar; open-time summary) · re-plan banner · PLANNER (8h/expand, drag-reorder; Alt-drag = free placement) · Flight-plan staging (the details drawer's Flight Plan tab) · FAB → ElementWizard modal (AC/AD) |
| Focus | /focus |
Today-only single-column list (upcoming / collapsed past); Time-Sense hero + Focus Ritual launcher; header mirrors Today's card (Brief · Focus goals · day note) + FAB; reminders card offers only today's tasks |
| Calendar | /calendar |
Day / week / month grid · five-star heatmap (month view) + note dots · Weekly Review panel (week toggle) · overlay filter (Direction + Category, brand ↔ colourful palette toggle — §1.2) · "Plan my week" (Weekly Planning Assistant: fill week · schedule tasks · review) · "Schedule Day" (fill-then-place intake) |
| Tasks | /tasks |
Filterable backlog with priorities, tags, notes, subtasks, dependencies; task-chip merged direction-icon/done toggle; Routine tasks grouped by routine with end-of-day archive (AC). Hosts the Objectives tree (Goal › Project › Objective) and Routines as URL-synced tabs (?tab= — tasks, objectives, habits, routines, timeblocks — with ?focus=<id> deep-linking into the Objectives tree; AF), plus a date-derived New Today section for auto-promoted suggestions. Section membership is pure derivation (lib/task-sections.ts), precedence Routine › Scheduled › New Today › Open (§1.3) — Scheduled sits ahead of New Today, so placing a task retires its New Today card on placement rather than at midnight |
| Balance | /balance |
Core-Dimension satisfaction/importance bars + Balance Index; each dimension's "N open" button opens a client-joined modal of its related open tasks (§1.2); a second Metrics tab holds the task metrics and the Triality tiles |
| Tags | /tags |
direction / intention / category taxonomy; weighted cloud; mute / delete; per-chip Category select on both tabs (§1.2) |
| Jobs | /jobs |
Scheduled job registry; run any job on demand, or Run all; execution log. Operator-only: behind the login gate the nav entry is hidden from members and viewers, and the API answers 403 without the admin scope |
| Settings | /settings |
Five tabs. Profile — your name, sign-in handle and email; Team (operator-only, AG: accounts, roles, sign-up invite codes); Backup & Export; First-Time Experience replay; in the mobile shells, the server address. Preferences — schedule patterns (day types, per-direction windows), planner display, pattern tags, time-block categories, People, time-block and routine settings, reminders with the Web Push opt-in, recurrence duration, Clear Date Range. Connections — model picker, Companion Memory, Integrations (Google OAuth client), Connected accounts, Email suggestions. The two ingestion windows (§1.3) live here: a Look ahead field under the memory-pull toggle, and an Email suggestions card with Look back (Google-visibility-gated like the rest of that surface). Learning — the four weekly learning readouts (classification accuracy, suggestion dismissal rate, Auto placement survival, AI day-ordering pass rate), read from GET /api/classification/accuracy, /api/dismissal-learning/trend, /api/placement/survival and /api/plan-loop/trend, with the sample size behind every figure and a clear button on the first three (the plan-loop card is read-only by design); a null rate renders as a gap, never 0%. Rendered outside the Settings form, because its buttons act at once and feed no setting. Logs — Weekly-Planning-Assistant session logs |
The nine routed views are /, /focus, /calendar, /tasks, /tags, /priorities, /balance, /settings, /jobs (client/src/App.tsx), plus the unauthenticated /login, /signup and /reset-password, which exist only when the deployment gates the SPA behind a login (AUTH_MODE=sso; test and standalone render the shell with no login route). In the iOS and Android shells a first-run Connect to your server screen (views/ConnectServer.tsx) shows before any of this until a server address is saved. Legacy paths /inbox, /objectives, /patterns redirect into their host tab — the Inbox surface was retired in Phase AF (ingested possible_tasks auto-promote, no manual triage), and Objectives/Routines became Tasks-view tabs. (/routines is not a route and never redirected — it 404s.) /priorities (wizard) and /balance (dashboard) are the life-priorities layer, §1.1.
SSE drives live updates across all views — task / block / job / reminder events are broadcast owner-scoped. (The iOS and Android shells poll GET /api/sync/changes instead of holding the stream, §12.)
Auth modes (set via AUTH_MODE):
| Mode | Use case | Mechanism |
|---|---|---|
test |
Dev / CI (forbidden in production) | System owner injected; no token required |
standalone |
Headless API access: REST / GraphQL clients and agents (the browser UI does not attach API keys) | Hashed x-api-key in api_keys table (SHA-256, crypto.timingSafeEqual); mint one with pnpm --filter server key:issue. The SSE stream also accepts the key as ?api_key= |
sso |
CI Hub appliance; any multi-user or exposed install | Login mints a signed, httpOnly cp_session cookie (Koa app.keys from SESSION_SECRET; unset, the server generates one once and keeps it beside the database in .session-secret); every request is authenticated by that cookie, never a Bearer token. Two ways in, ending in the same cookie: local username/password (argon2id), served in both sub-modes (POST /api/auth/login, the break-glass operator seeded from AUTH_DEFAULT_*, self-serve sign-up below), and Companion Portal (CI-Portal) OIDC (OAuth 2.1 + PKCE RP; id_token verified against the portal's JWKS, RS256 or EdDSA, once at the callback; endpoints discovered via CIPORTAL_ISSUER). DISABLE_OIDC=true turns the portal off and leaves local sign-in as the only login. |
Sessions are stateless signed cookies (cp_session: 30 days, HttpOnly, SameSite=Lax, Secure whenever the request is HTTPS, which behind a TLS-terminating proxy needs TRUST_PROXY=true) stamped with owners.session_epoch; bumping that epoch (a Phase V password reset, or an operator changing the account's role) instantly invalidates every outstanding cookie for that owner. Logout clears only the cookie of the browser that calls it; there is no logout-everywhere. The older generic AUTH_JWKS_URL / AUTH_ISSUER / AUTH_AUDIENCE / AUTH_CONTRACT_VERSION vars predated the CI-Portal OIDC flow and have been retired — they were parsed into config.auth but consumed by no code path, so setting them looked like hardening while enforcing nothing. Their live equivalents: the JWKS endpoint and expected iss come from CIPORTAL_ISSUER discovery (with every discovered endpoint origin-checked against the issuer), and the aud check uses CIPORTAL_CLIENT_ID.
Authorization — roles → scopes (Phase AG, mig 0125). Authentication answers who you are; owners.role answers what you may do. One choke point converts role → scopes: roleToScopes (lib/auth/scopes.ts), replacing a hardcoded [READ, WRITE, ADMIN] triple that made every authenticated owner an admin.
| Role | Scopes | Notes |
|---|---|---|
operator |
read · write · admin | Existing owners backfilled to this |
member |
read · write | Portal auto-provision and self-serve sign-up default here — closes the auto-admin hole. The one exception: the first account on an instance with no operator becomes operator |
viewer |
read | Read-only |
An unknown/unrecognized role degrades to read-only, never to admin — the failure mode is a locked door, not an open one. Account management (routes/accounts.ts: GET/POST/PATCH/DELETE /api/accounts, /:id/invite and /signup-codes) is operator-gated; onboarding is a password-less invite plus a reset-token link, or a sign-up code (below). A role change bumps the target's session epoch so they sign in again with their new scopes, and the last operator can be neither demoted nor deleted.
Token crypto: OAuth access/refresh tokens are sealed with AES-256-GCM in connection_tokens. The 32-byte key-encryption key is supplied as CP_KEK_B64 — never committed; never in a log. Each row stores a key_version so keys can be rotated without re-authenticating (retired keys stay available as CP_KEK_B64_V{n}; the active version is CP_KEK_VERSION). The same key seals the Companion Memory credentials and the in-app Google client secret.
Log redaction: server/src/lib/logger.ts recursively masks any key matching the secret pattern (tokens, client_secret, authorization, code, kek, iv, ciphertext, …) at any depth before serialization. URL query params carrying token / code / client_secret are scrubbed from string values. Cycle-safe, depth-guarded.
Local sign-up and onboarding. Local username/password login (POST /api/auth/login, argon2id) is served in both sso sub-modes, with one constant-work verify whether or not the username exists. Self-serve sign-up (POST /api/auth/signup, the /signup page; sso only) follows AUTH_SIGNUP: invite (the default) needs a single-use, 7-day invite code that an operator mints in Settings → Team (POST /api/accounts/signup-codes; stored as a SHA-256 hash in signup_codes, migration 0172, shown once, revocable), open needs no code, off removes the endpoint (404). The person picks their own username and password; an operator never sees one. The first account on an instance with no operator becomes the operator, by whichever path creates it, and every later account is a member. A first-time Companion Portal sign-in is never provisioned silently: with AUTH_AUTO_PROVISION=true the callback parks the verified identity in a signed 10-minute cookie and the person confirms on a no-JS page; with it off the sign-in is refused (403). Login, sign-up and reset requests share a per-IP in-memory limiter (AUTH_RATE_ISSUE_MAX attempts per AUTH_RATE_WINDOW_MS, default 5 per minute); reset requests are also limited per identifier and answer the same way whether or not the account exists.
Cross-origin and CSRF. CORS is closed by default: app.ts echoes an Origin back only when it is in the explicit allowlist CORS_ALLOWED_ORIGINS (comma-separated, empty by default, never *), because the SPA is same-origin (koa-static in production, the Vite proxy in dev) and a wildcard would let any page that gets a browser to attach valid credentials read authenticated JSON. There is no CSRF token on the JSON API; the session cookie is SameSite=Lax, so it is not sent on cross-site POSTs (a forged cross-site logout sees no cookie and does nothing). The browser-driven login flows add their own binding: Companion Portal /start sets a signed, single-use cp_login_state cookie that the callback must match to the returned state (login-CSRF defence), and the first-time sign-in confirmation form carries a CSRF token that is compared in constant time against its signed cp_portal_intent cookie.
Security headers (securityHeaders() in app.ts) go on every response, the SPA shell included: a Content-Security-Policy with script-src 'self' (no inline script), inline styles allowed, no foreign origin (Manrope is bundled with the client through @fontsource/manrope), connect-src limited to the app itself plus the Sentry ingest origin when crash reporting is on plus anything in CSP_EXTRA_CONNECT_SRC, and frame-ancestors 'none'; X-Content-Type-Options: nosniff; X-Frame-Options: DENY; Referrer-Policy: strict-origin-when-cross-origin; a Permissions-Policy that denies camera, microphone and geolocation; and Strict-Transport-Security only when the request itself is HTTPS, so opening http:// locally never pins a browser to TLS. The self-hosted GraphiQL sandbox gets its own, looser policy. Everything under /api sits behind requireAuth except a short public list in lib/auth/requireAuth.ts: health, the telemetry-policy and auth-config probes, login and logout, sign-up, password reset, the Portal login pair and its confirm page, and the Google and Companion Memory OAuth callbacks.
flowchart LR
App -->|"GET /api/oauth/google/start"| OAuthFlow
OAuthFlow -->|PKCE/S256 Auth Code| Google["Google OAuth"]
Google -->|callback + id_token| App
App -->|seal tokens| CT[("connection_tokens")]
App -->|"CP_MCP_COMMAND stdio"| MCP["MCP child process"]
MCP -->|"inject access token"| GCal["Google Calendar"]
MCP -.->|"Gmail tools only with CP_GOOGLE_ENABLE_GMAIL"| Gmail["Gmail"]
The McpGoogleSession owns the MCP child process and hands the child the per-account access token (fetched from connection_tokens, refreshed if needed) in its environment as GOOGLE_ACCESS_TOKEN — never in argv, and the child's whole environment is PATH plus that token, so it never sees CP_KEK_B64 or DB_PATH; a 401 forces one token refresh and a respawn. The child never persists credentials of its own. With GOOGLE_MODE unset (the default) the Google integration starts off, but an admin can activate it in-app: Settings → Connections → Google OAuth client stores the client id (plain) + client secret (KEK-sealed) in the server-global app_config table and hot-applies live mode at runtime (services/google-config.ts, behind the admin-scoped GET/PUT /api/google-config; requires CP_KEK_B64). An explicit GOOGLE_MODE env pins the mode and makes the in-app setup read-only. GOOGLE_MODE=fixture is an explicit dev/demo opt-in that uses committed sample data with no network calls (refused under NODE_ENV=production unless CP_ALLOW_FIXTURE=true). In live mode each user connects their own account in Settings → Connections, and the ingest jobs run only for owners with an active connection. The Calendar MCP child defaults to the bundled server (src/scripts/mcp-google-server.ts), overridable via CP_MCP_COMMAND. That server exposes only the four calendar_* tools, so live Gmail ingest needs both CP_GOOGLE_ENABLE_GMAIL=true (which also adds the Gmail scope to the consent request) and a Gmail-capable CP_MCP_COMMAND. The iOS and Android shells do not offer Connect Google (the OAuth redirect cannot finish inside the app); connect from a browser and the connection appears in the app.
Circuit breaker and retries: 5 consecutive MCP failures open the breaker for 30s (CP_MCP_BREAKER_THRESHOLD / CP_MCP_BREAKER_COOLDOWN_MS). Each call is bounded by CP_MCP_TIMEOUT_MS (default 30s) and a timeout is not retried; 429 and 5xx responses are retried up to CP_MCP_RETRIES times (default 3) with exponential backoff and jitter; other 4xx responses are not retried.
A pull-first integration with the CI-Server companion-memory graph. Two independent flags, both default off (⇒ byte-identical): memory_pull_enabled gates the read-only pull (suggestions, brief, ordering, estimate), and memory_sync_enabled separately gates the one-way push_memory sync — push can run with pull off, and vice-versa. Creds are per-owner in connection_tokens (provider='ci_memory', KEK-sealed) — either a manually pasted key/bearer, or a CI-Portal OAuth grant (AA0b: OAuth 2.1 + PKCE with offline_access; the connector self-refreshes the bearer; the iOS and Android shells offer only the pasted URL + key form, because a Portal grant is a redirect out and back that the app cannot complete). With MEMORY_MODE unset (the default) the transport is off and every consumer sees "not connected"; MEMORY_MODE=fixture is a dev/demo opt-in that reads a committed sample graph with no network; MEMORY_MODE=live calls the real graph API. Every transport/4xx/5xx failure surfaces as MemoryUnavailable, so all consumers degrade deterministically.
flowchart LR
subgraph Pull["Pull (read-only) · memory_pull_enabled"]
MS["memory_suggest job<br/>(every 6h)"] -->|"graphData next memory_window_days"| MEM["CI-Server<br/>companion-memory"]
MEM -->|events + participants| MS
MS -->|stage| INBOX[("possible_tasks<br/>source_kind=memory")]
BRIEF["Daily Brief / EOD<br/>(AA2)"] -->|graphContext| MEM
PLAN["autoplan authoring<br/>(AA3a ordering)"] -->|graphData| MEM
end
subgraph Push["Push (one-way) · memory_sync_enabled"]
PM["push_memory job<br/>(daily 02:00)"] -->|"POST /v1/events"| MEM
end
CT[("connection_tokens<br/>provider=ci_memory")] -.->|creds| MS
CT -.->|creds| PM
Consumers of the pull: AA1 stages upcoming events (the next memory_window_days, default 2) as possible-task suggestions and auto-promotes the batch into Tasks (AF-3; idempotent on source_ref, at most 5 pending, dismissed-is-terminal); AA2 folds a graphContext excerpt into the Daily Brief / EOD alignment; AA3a biases autoplan ordering toward memory-related tasks; AA3b seeds a better default estimate for memory-drafted tasks from the owner's own history. Push (AA4) is separate and one-way — completed tasks / habit occurrences / reflections as capture envelopes, gated by memory_sync_enabled + per-entity scope toggles, watermarked by memory_last_synced_at.
Every provider's credentials live in one table (connection_tokens: google, ci_memory, fixture), and they are read through one shape — ConnectionRecord in lib/connections.ts — served by both GET /api/connections (all providers) and GET /api/integrations → google.connections. It replaced a ConnectionHealth / ConnectionSummary pair that each called itself "the locked contract" over the same rows. status is always the mapped connected | expired | inactive; the raw connection_tokens.status column never leaves the server. Every active row is opened, on every caller's behalf — connected means the stored token was decrypted, not merely that a row exists. Decryption used to be opt-in (listConnections(owner, { withExpiry })) to save a KEK load plus an AES-GCM open per row, which meant the plain list reported connected for a token the app could no longer decrypt while the integrations card reported inactive for that same row — one connection, two answers, and a green badge for an operator whose KEK had rotated. Measured, the saving was ~9µs per row (~60µs on a one-connection list), so the option is gone and the two endpoints cannot diverge. mappedEventCount is Google-only and absent, not 0, elsewhere. The SELECT behind the plain list names only safe columns — never iv / auth_tag / ciphertext / key_version — which the no-leak guards in connections-unified.test.ts and connections-route.test.ts assert on the serialized response.
There is one disconnect: DELETE /api/connections/:provider/:account → disconnectConnection() (best-effort Google revoke, then hard-delete of the token row and its outbound_queue rows; for ci_memory it also clears the scheduling_prefs.memory_account label). The Google-specific duplicate route is gone, and POST /api/memory/disconnect delegates to the same function. Connecting stays provider-specific by nature — Google OAuth, CI-Portal SSO, and memory's pasted URL + key have nothing in common — so only list / status / disconnect unify. GET /api/memory/status remains for the pull/sync flags and creds source, which are not connection facts, but its connection block is sourced from listConnections so the two endpoints cannot disagree.
Connector failures share one error contract as well: ConnectorUnavailable (lib/connector-error.ts) is the base of OllamaUnavailable, McpUnavailable and MemoryUnavailable, which keep their exact names and .name values. One catch (err instanceof ConnectorUnavailable) covers "a connector is down"; each specific class still narrows for callers that care which.
Phase N turns Companion Planning from a workday planner into a life planner.
flowchart TD
O["Objective (Goal → Project → Objective)"]
OT["objective_tasks"]
T["Tasks"]
FP["flight_plan"]
DP["daily_plan"]
R["reflections"]
EC["estimate_calibration"]
O -->|progress roll-up| OT
OT -->|links| T
T -->|staged into| FP
FP -->|AI-ordered into| DP
DP -->|outcome stamped by review_day| DP
R -->|today goal + weekly goals| O
T -->|actual vs estimated minutes| EC
Key decisions:
- Objective level is server-derived from tree height (leaf-with-tasks = objective, node-of-objectives = project, node-of-projects = goal) — re-stamped on the node + ancestors on every (re)parent; never accepted in PUT/POST body. Tasks link only to leaf objectives.
- Progress is
NULLwhen there are no tasks or children (not 0 — aNULLis a rot signal). daily_plan.outcomeis write-once (WHERE outcome IS NULLguard) —review.tsstamps'deferred'/'missed'before hard-deleting the stale block.- Estimate bias uses the median (p50), not mean — robust against outlier interrupted sessions.
- Quartile computation is in JavaScript (sorted array) — SQLite has no
PERCENTILE_CONT.
Two-clock merge strategy: every mutating route increments a monotonic version counter and records a field_ts (per-field timestamp) alongside the updated row. A sync_events append-only log backs Last-Event-ID SSE catch-up, and GET /api/sync/changes?since=<seq> returns the same rows as JSON for clients that poll instead of streaming. The iOS and Android app shells poll it (every 15 s while the app is on screen, on resume, and shortly after the app's own writes; client/src/lib/live-poll.ts), because their EventSource cannot carry the session cookie. A cursor older than the 500-event window, or ahead of the owner's log, answers resync: true: refetch everything, then carry on from the returned cursor. The prune_sync_events job trims log rows older than 30 days, and POST /api/sync applies at most 200 ops per batch. Synced entities: tasks, time blocks, objectives, and objective↔task links (objectives joined the allowlist in 0087; links merge via an unlink tombstone (linked = 0) so a removal round-trips under field-LWW).
sequenceDiagram
participant Device as Offline Device
participant Server
Device->>Device: write to IndexedDB op-log
Device->>Server: POST /api/sync (op-log batch)
Server->>Server: field-level merge (monotonic per field)
Server->>Server: append to sync_events
Server-->>Device: merged state + new seq cursor
Server-)All: SSE broadcast (other devices catch up via Last-Event-ID)
Note over Device,Server: App shells poll GET /api/sync/changes?since=N instead of holding SSE
Monotonic completions: a concurrent edit on another device can never un-complete a task — applyMonotonic pins status: 'done' and keeps the earliest completed_at in the merge. The same guard pins task placement: a stale offline op carrying status:'open' can't revert a server-side scheduled (mirrors the done pin), so placement isn't lost across devices.
Conflict surface: a genuine conflict (two devices edited the same field in the same version window) shows a persistent banner — not a toast — so the user can review.
Clamp, never reject. The merge has no channel to tell a client "your op was refused", so a rejected op would leave the two sides permanently divergent. Instead applyOp accepts the op and clamps the value — it still wins its LWW comparison, and the client is told what actually landed via applied. The done/scheduled pins above are one instance; the objective tree-shape guard is the other:
clampToLiveParent(sync/apply.ts) forces an incomingobjective.parent_idto a parent that exists in the owner's live tree, elseNULL. Three cases collapse to "not a live parent of yours": archived (it exists, so the FK passes — this is the corrupt edge the API refuses vialiveParentExists), missing (the FK would otherwise throwFOREIGN KEYout ofapplyOpand roll back every other op in the batch), and another owner's. NULL is the honest landing spot: it is wherebuildTreeputs the node anyway, but now the DB says so, solevelcan be restamped. It is a separate step fromapplyMonotonic, whose(field, row, value)signature cannot reach the DB.applySyncedObjectiveUpkeep(routes/sync.ts→services/objectives.ts) re-runs the service-level upkeepapplyOpbypasses — chiefly, a syncedarchived_atmust cascade over the live subtree exactly asarchiveObjectivedoes, or archiving offline orphans every child (andbuildTreethen re-roots them to top level). This is the same seam, and the same reason, as the task→block done-state mirror alongside it:applyOpwrites columns directly and never goes through the services. An un-archive deliberately does not cascade — restore is root-only.
archived_at/archive_root_id are ordinary mergeable fields (so archiving works offline) and are deliberately not pinned in applyMonotonic — a pin would make archiving irreversible through sync and defeat the 48h window. purge_after and level/progress_cache are server-owned and absent from the allowlist entirely.
Because LWW guarantees no arrival order, both orders converge on a well-formed tree: re-parent then archive → the cascade catches the child; archive then re-parent → the clamp catches the op. The end states differ (LWW is order-sensitive by nature); the invariant holds either way.
pnpm build && pnpm --filter server start
# Runs on port 3848; access from any Tailscale deviceInstall as a launch agent: bash ops/scripts/install.sh installs dependencies, builds the client and, on macOS, registers every plist in ops/launchd/ (the server is com.companion-planning.server.plist, with REPO_PATH substituted; the rest are the autocommit and daily/weekly/monthly summary helpers). On Linux use the systemd user unit ops/systemd/companion-planning.service (edit WorkingDirectory and ExecStart, then systemctl --user enable --now companion-planning); the host walkthrough is docs/local-tailscale-setup.md. Set DB_PATH explicitly on Linux or Windows (the default is ~/Library/Application Support/CompanionPlanning/companion.db, which only suits macOS). The launchd plist deliberately does not set NODE_ENV=production; the systemd unit does, so it needs AUTH_MODE set in server/.env.
docker run -d --name ci-planning \
-p 3848:3848 \
-v ci-planning-data:/data \
-e TZ=America/Los_Angeles \
-e AUTH_MODE=sso \
-e DISABLE_OIDC=true \
-e AUTH_DEFAULT_USERNAME=you \
-e AUTH_DEFAULT_PASSWORD='pick-a-strong-password' \
ghcr.io/companionintelligence/ci-planning:latestThe image sets NODE_ENV=production, which refuses the default AUTH_MODE=test at boot, so a bare docker run with no auth variables exits instead of serving an open instance. The form above is the password-only install, the one docker-compose.yml ships; SESSION_SECRET is generated once and kept in /data/.session-secret unless you set it. Drop DISABLE_OIDC and set PUBLIC_BASE_URL and AUTH_AUTO_PROVISION=true to add Companion Portal sign-in (the seeded local account stays the operator; leave AUTH_DEFAULT_PASSWORD unset and the first Portal sign-in claims the operator role instead); AUTH_MODE=standalone is for API clients only (mint a key with docker exec ci-planning node_modules/.bin/tsx src/cli/issue-key.ts). The image runs tsx src/index.ts from /app/server as PID 1 with DB_PATH=/data/companion.db, reads operator overrides from /data/.env when it exists, and carries a HEALTHCHECK on GET /api/health. Images published with a Sentry DSN report crashes until CI_TELEMETRY=off or CI_LOCAL_ONLY=true (docs/operations/error-reporting.md).
On CI Hub, install from the marketplace — the Hub mounts ${APP_DATA_DIR}/data at /data and wires OLLAMA_URL automatically.
docker-compose.yml runs the app and an ollama service on one network (OLLAMA_URL=http://ollama:11434) as the password-only install (AUTH_MODE=sso, DISABLE_OIDC=true; SESSION_SECRET, AUTH_DEFAULT_USERNAME and AUTH_DEFAULT_PASSWORD come from .env, see .env.example). docker compose --profile pull run --rm ollama-pull fetches the default models once. A pgvector postgres service sits behind --profile postgres for the in-progress schema port (DATABASE_URL / STORE_BACKEND=postgres); the app serves SQLite, and Postgres is not a supported deployment yet. The CI Hub marketplace install ignores this file.
mobile/ios and mobile/android are Capacitor shells that wrap the client/ bundle (pnpm --filter client build:mobile writes client/dist) as a native app. They are thin clients of a server you run: first run asks for its address (checked against /api/health and /api/auth/config, kept on the device, changeable under Settings → Profile → Server). iOS 16.4+ and Android 7+ with System WebView 111+. They are not pnpm workspace members, the manual mobile-build.yml workflow builds a debug APK and an unsigned Simulator .app, and they are not store-ready. What they change on the server side:
- No CORS setting is needed. Requests go through the native HTTP stack, so
CORS_ALLOWED_ORIGINSstays empty. - Live updates are polled with
GET /api/sync/changes(§12), because anEventSourcein the shell cannot carry the session cookie. - Sign-in is username/password only. Companion Portal sign-in, Connect Google and the Companion Memory Portal connect are redirects out and back that the shell cannot complete, so make those connections from a browser. Web Push, the service worker and the export links are not available in the app.
Full notes: README.md.
| Var | Default | Purpose |
|---|---|---|
PORT |
3848 |
HTTP port |
DB_PATH |
~/Library/…/companion.db |
SQLite path |
NODE_ENV |
(unset) | production turns on the fail-closed boot guards (refuses AUTH_MODE=test, and GOOGLE_MODE / MEMORY_MODE=fixture without CP_ALLOW_FIXTURE=true); the Docker image sets it |
AUTH_MODE |
test |
test / standalone / sso |
SESSION_SECRET |
(generated) | Signs the session cookie; sso needs one. Unset, the server generates it once and keeps it in .session-secret beside the database. Pin it for more than one replica |
DISABLE_OIDC |
false |
sso only: true turns Companion Portal sign-in off, leaving username/password |
AUTH_DEFAULT_USERNAME / AUTH_DEFAULT_PASSWORD |
(unset) / username admin |
sso only: seeds the first local owner as operator on first boot. Required with DISABLE_OIDC=true, or the server refuses to start with no login path |
AUTH_SIGNUP |
invite |
sso only: local self-serve sign-up at /signup — invite (needs an operator-minted code), open, or off |
AUTH_AUTO_PROVISION |
false |
true lets a first-time Companion Portal sign-in create an owner (after the confirmation page); otherwise it is refused |
CIPORTAL_ISSUER |
https://hub.ci.computer/api/auth |
Companion Portal discovery base; the client id defaults to ci-planning (CIPORTAL_CLIENT_ID) |
PUBLIC_BASE_URL |
http://localhost:3848 |
The address people open; the OAuth redirect URIs are built from it |
TRUST_PROXY |
false |
true behind a TLS-terminating proxy: honour X-Forwarded-* (Secure cookies, the real client IP for the rate limiter) |
CORS_ALLOWED_ORIGINS |
(empty) | Comma-separated browser origins allowed to call /api cross-origin; never * |
CSP_EXTRA_CONNECT_SRC |
(empty) | Extra origins the browser may call (CSP connect-src) |
OLLAMA_URL |
http://127.0.0.1:11434 |
Ollama endpoint |
GOOGLE_MODE |
(unset ⇒ off) | live (real OAuth) or fixture (offline sample data — dev/demo opt-in) |
MEMORY_MODE |
(unset ⇒ off) | CI-Server memory transport: live or fixture (offline sample graph — dev/demo opt-in) |
CI_SERVER_URL |
(unset) | CI-Server memory API base — the single-tenant fallback base, and required by the CI-Portal memory connect flow even under SSO |
CI_SERVER_API_KEY |
(unset) | Shared fallback API key — ignored under multi-tenant SSO (per-owner creds come from connection_tokens) |
CP_KEK_B64 |
(unset) | 32-byte AES key (base64) that seals stored OAuth tokens, Companion Memory credentials and the in-app Google client secret; required for the in-app Google setup |
CP_SYNC_WRITEBACK |
false |
Push local blocks to Google Calendar |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY |
(unset ⇒ generated on first boot) | Web Push keypair; setting both pins one, otherwise the server generates and stores a pair |
SNAPSHOT_RETAIN |
7 |
Snapshots the snapshot_db job and pnpm --filter server snapshot keep |
Full variable reference: README.md and server/.env.example
mobile/ios and mobile/android are Capacitor shells around the client/ build (pnpm --filter client build:mobile, output client/dist). They call a server the person runs through native HTTP, which keeps the session cookie and needs no CORS entry. Live updates poll GET /api/sync/changes?since= every 15 s while the app is visible, and again at once when it resumes or the network returns; the browser keeps /api/events. Sign-in is password-only. Settings hides the controls a WebView cannot run: Export JSON, the calendar export, the push switch, and account creation. The Android build number and name come from -PappVersionCode and -PappVersionName; the iOS ones from xcodebuild MARKETING_VERSION=… CURRENT_PROJECT_VERSION=….
Everything lives in the single SQLite file at DB_PATH.
| Method | How | Cadence |
|---|---|---|
| Built-in snapshots | pnpm --filter server snapshot or the snapshot_db job (files land in <DB dir>/backups/companion-<timestamp>.db) |
Daily at 02:00; keeps SNAPSHOT_RETAIN (default 7) copies |
| Litestream | litestream replicate -config ops/litestream.yml |
Continuous WAL replication to object storage |
| Export/import | GET /api/export → POST /api/import |
On-demand; owner-scoped JSON |
Restore. Snapshots restore offline: stop the server, then run pnpm --filter server restore -- <snapshot-file> (src/scripts/restore.ts). It validates the snapshot, refuses to run while the server (pidfile) or any other writer holds the database, moves the current .db with its -wal and -shm aside as one set into pre-restore/ beside the database (it never deletes it), copies the snapshot into place and runs forward migrations. A Litestream replica restores with litestream restore -config ops/litestream.yml -timestamp <ISO-8601> /data/companion.db. A device whose sync cursor is ahead of the restored log is told to resync (resync: true, §12).
⚠ Do not sync the live .db file with Syncthing/rsync — SQLite WAL spans three files (.db, .db-wal, .db-shm) and file-sync tools copy them at different moments, producing a corrupt or inconsistent database. Use pnpm --filter server snapshot or Litestream.
Companion Planning occupies the gap between open-source tools (no auto-scheduling) and commercial AI schedulers (cloud-only, no data ownership):
| Dimension | Companion Planning | OSS alternatives | Commercial AI (Motion, Reclaim) |
|---|---|---|---|
| Local-first | ✅ SQLite source of truth | ✅ (Super Productivity, Taskwarrior) | ❌ cloud |
| AI auto-schedule | ✅ local LLM | ❌ | ✅ cloud LLM |
| Two-way calendar sync | ✅ | ❌ or limited | ✅ |
| Life-planning layer | ✅ objectives + reflections | ❌ | partial |
| Data ownership | ✅ fully local | ✅ | ❌ |
| Licensing | PolyForm Noncommercial 1.0.0, free, source-available | ✅ open source | ❌ closed SaaS |
The differentiator: local-first + AI-assisted day planning + life-planning layer, all on your own hardware — free under PolyForm Noncommercial 1.0.0 with the source public.
Terms whose plain-English reading is ambiguous, or that mean different things in different parts of the tree. Grep-facing: each entry names the code that owns it.
The word is load-bearing in three separate domains. Nothing is shared between them, and
a grep for pin hits all three — so each site carries a comment saying which it is.
| Sense | What it is | Owned by |
|---|---|---|
| Operator env pin | An operator fixed a value by hand in the environment, so the app must not choose it for them: a model tier (OLLAMA_FAST/OLLAMA_QUALITY/OLLAMA_EMBED), the Google connector mode (GOOGLE_MODE), or the Web Push keypair (VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY). Every one of them lives in config.pins. Surfaces to Settings as ModelSource = 'pin'. |
server/src/lib/config.ts (PinConfig), server/src/lib/models.ts |
| Calibration combo pin | The OWNER confirming "keep learning a separate estimate bias for this tag combination" — the §2.3 hybrid confirm workflow. Rows in calibration_combo_pins; an unpinned combo is only ever a suggestion. |
server/src/services/calibration.ts, client/src/components/CalibrationPins.tsx |
| Plan pin | A task start time the owner locked in free text ("gym at 7"), parsed by POST /api/plan/instruct and placed as an immovable obstacle block. Contrast a hint, which only biases ordering. |
server/src/agents/plan.ts (PlanPin) |
live / fixture / off — the transport selector shared by the Google and CI-Server
memory connectors (ConnectorMode / ModeConfig in server/src/lib/config.ts).
fixture serves committed sample data and is always an explicit opt-in: it is refused
under NODE_ENV=production unless CP_ALLOW_FIXTURE=true.
The two connectors differ in ONE way, and the asymmetry is deliberate:
config.google.modeis boot-pinned.services/google-config.tshot-applies an operator's in-app setup by mutating the singleton; a lazy re-read from env would silently undo that.config.memory.mode(andconfig.memory.serverUrl) are getters that re-readMEMORY_MODE/CI_SERVER_URLon every access, becauseconnectors/memory.tsresolves its transport per call and the suite toggles those vars mid-run.
Both are still resolved in lib/config.ts and nowhere else — connectors/memory.ts's
memoryMode() is a thin delegate to config.memory.mode.