From c69e4a43dc177d4bc66b574c51a8b6ab8f6da940 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:19:13 +0000 Subject: [PATCH 01/22] =?UTF-8?q?feat(WS-27o):=20recurring=20tasks=20?= =?UTF-8?q?=E2=80=94=20no=20scheduler,=20because=20the=20spec=20forbids=20?= =?UTF-8?q?one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Every operations cadence is recurring. Without it those live in someone's head or in ClickUp." NO SCHEDULER, AND THAT IS FORCED RATHER THAN CHOSEN. §5's non-goals: "A second automation engine. ADR-028/D6: /workflows is the only engine; WS-27 contributes events and node types to it." A recurrence worker here would be exactly that. So the successor is created WHEN A TASK CLOSES — apply_status_transition already owns that moment, so a task finished from the board, from My work, from an automation or from a bulk edit all recur identically. A second call site would be a fifth way to finish a task that forgets to. What that costs, stated rather than discovered: a series only advances when somebody finishes the current one. A monthly report nobody closes does not pile up twelve copies, which is right; a daily stand-up nobody ticks does not appear tomorrow, which is the honest limitation. Materialising ahead is already reachable through the engine that owns scheduling — a cron trigger plus the pm_task node WS-27f added — so nothing here has to be undone to get it. THE ANCHOR IS PER RULE, because the two answers mean different things. `due` keeps the schedule: "stock count on the 1st" stays on the 1st however late the last one was closed, so the series does not drift. `completed` measures the interval from when the work was actually done: "water the plants every 3 days" restarts when you water them. A `due` anchor also CATCHES UP — a monthly task closed six weeks late would otherwise produce a successor already overdue the moment it appeared — and the missed occurrences are skipped rather than backfilled, because nobody wants four copies of a stand-up they did not attend. THE DATE ARITHMETIC is where this is either right or quietly wrong for a year, so it is pure and each case is one assertion: - January 31st monthly: the day is clamped at COMPUTATION time and stored as asked. Storing the clamped value permanently demotes the rule to the 28th after its first February. - February 29th yearly: the same shape, once every four years. - "Every other Monday and Thursday": within a week the rule takes the next allowed day, and only jumps `interval` weeks when the week runs out. A naive +14 days alternates between the two days instead of giving both days of every second week. - A stand-up at 09:00 stays at 09:00. Closing a task twice must not spawn twice. A task can cross into `done` repeatedly — close, reopen to add a note, close again — and every crossing reaches the same seam. recurrence_spawned_at is the guard, and it is never cleared: reopening undoes completed_at but does not un-emit a successor that already exists and may already have been worked on. Stopping a series keeps the work: the rule is deleted and its tasks detached, not removed. They are real work, some finished, and a "stop repeating this" button that swept away three months of completed reports would be the last time anybody pressed it. TWO BUGS THE LIVE RUN CAUGHT AND READING COULD NOT: 1. The weekly CHECK passed the very row it existed to reject. CHECK (freq <> 'weekly' OR array_length(weekdays, 1) >= 1) looks correct and is not: array_length('{}', 1) returns NULL, NULL >= 1 is NULL, and a CHECK only FAILS on false. A weekly rule with no weekdays inserted happily. coalesce(…, 0) fixes it, and a test asserts the coalesce is present because the hermetic suite has no database to try the expression on. 2. _next_number and _default_status were reimplementations, and one invented a column (last_number; the real one is last_value). Both replaced by core's own next_task_number and load_default_status — the same mistake WS-27n had just been careful to avoid, made two tickets later in the same package. A third, caught by its own test: int(rule.get("interval") or 1) turns an explicit 0 into "every 1" — a typo that looks exactly like a save, and one the database's CHECK would then have refused as a 500 rather than a 422. In the browser the SENTENCE is the feature. A form of five controls is a shape; "Every 2 weeks on Mon, Thu, keeping to the schedule" is something somebody can check before committing to it — shown live rather than on save, because picking the wrong anchor is invisible until a cadence has drifted for three months. The occurrence limit reads as what is LEFT rather than the cap, and switching frequency clears the fields the new one does not use so a stale day_of_month cannot reappear. 45 hermetic + 27 vitest cases, 31 mutants killed and reverted byte-identical, 39 checks against a real Postgres. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../specs/project_management_app.md | 82 ++- ai-company-brain/work_plan.md | 2 +- .../gateway/routes/projects/__init__.py | 1 + .../gateway/gateway/routes/projects/core.py | 20 +- .../gateway/routes/projects/recurrence.py | 487 ++++++++++++++++++ infra/postgres/157_projects_recurrence.sql | 114 ++++ tests/unit/test_projects_recurrence.py | 437 ++++++++++++++++ .../app/projects/components/RepeatEditor.tsx | 281 ++++++++++ .../src/app/projects/components/TaskPanel.tsx | 2 + .../control_plane/src/app/projects/lib/api.ts | 20 + .../src/app/projects/lib/recurrence.test.ts | 194 +++++++ .../src/app/projects/lib/recurrence.ts | 172 +++++++ 12 files changed, 1809 insertions(+), 3 deletions(-) create mode 100644 apps/services/gateway/gateway/routes/projects/recurrence.py create mode 100644 infra/postgres/157_projects_recurrence.sql create mode 100644 tests/unit/test_projects_recurrence.py create mode 100644 workbench/control_plane/src/app/projects/components/RepeatEditor.tsx create mode 100644 workbench/control_plane/src/app/projects/lib/recurrence.test.ts create mode 100644 workbench/control_plane/src/app/projects/lib/recurrence.ts diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index 07e3f448..988c3b3b 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -945,7 +945,7 @@ interesting it is to build. | 4 | ~~**Custom fields**~~ | — | **WS-27l ✅ BUILT 2026-08-07** | | 5 | ~~**Tags**~~ | — | **WS-27m ✅ BUILT 2026-08-07** | | 6 | ~~**Bulk edit / multi-select**~~ | — | **WS-27n ✅ BUILT 2026-08-07 · unblocks g** | -| 7 | **Recurring tasks** | Every operations cadence is recurring. Without it those live in someone's head or in ClickUp | **WS-27o** | +| 7 | ~~**Recurring tasks**~~ | — | **WS-27o ✅ BUILT 2026-08-07** | | 8 | **Dependency and subtask UI** — `pm_task_links` and `parent_task_id` both exist, unreachable from the board | Data with no surface is a promise the product does not keep | **WS-27p** | | 9 | **Calendar / timeline view** | The third view ClickUp users actually use, after list and board | **WS-27q** | | 10 | **Global task search** | `?q=` exists on the list endpoint; there is no search surface | **WS-27r** | @@ -1479,3 +1479,83 @@ tests failed for a reason with nothing to do with the code under test. The probe matched first and the audience branch keys off `assignee AS who`, which only its own query has. A fake that dispatches on substrings needs its fingerprints to be *specific*, not merely present. + +### 11.13 WS-27o — recurring tasks (built 2026-08-07) + +*"Every operations cadence is recurring. Without it those live in someone's head or in +ClickUp."* + +Migration `157_projects_recurrence.sql`, `routes/projects/recurrence.py`, `lib/recurrence.ts` +and a repeat row in the task panel. 45 hermetic + 27 vitest cases, 31 mutants red, 39 checks +against a real Postgres. + +**No scheduler — and that is forced rather than chosen.** §5's non-goals: *"A second +automation engine. ADR-028/D6: `/workflows` is the only engine; WS-27 contributes events and +node types to it."* A recurrence worker here would be exactly that second engine. So the +successor is created **when a task closes**: `apply_status_transition` already owns that +moment, which means a task finished from the board, from My work, from an automation or from a +bulk edit all recur identically. A second call site would be a fifth way to finish a task that +forgets to. + +**What that costs, stated rather than discovered.** A series only advances when somebody +finishes the current one. A monthly report nobody closes does not pile up twelve copies — +which is right — but a daily stand-up nobody ticks does not appear tomorrow, which is the +honest limitation. Materialising ahead of time is already reachable through the engine that +owns scheduling (a cron trigger plus the `pm_task` node WS-27f added), so nothing here has to +be undone to get it. + +**The anchor is per rule, because the two answers mean different things.** `due` keeps the +schedule — "stock count on the 1st" stays on the 1st however late the last one was closed, so +the series does not drift. `completed` measures the interval from when the work was actually +done — "water the plants every 3 days" restarts when you water them. Neither is a sensible +global default. A `due` anchor also **catches up**: a monthly task closed six weeks late would +otherwise produce a successor already overdue the moment it appeared, which teaches people the +date is meaningless. The missed occurrences are *skipped rather than backfilled* — nobody +wants four copies of a stand-up they did not attend. + +**The date arithmetic is where this is either right or quietly wrong for a year**, so it is +pure and each case is one assertion: + +* **January 31st, monthly.** The day is clamped at *computation* time and stored as asked. + Storing the clamped value instead would permanently demote the rule to the 28th after its + first February. +* **February 29th, yearly.** The same shape, once every four years. +* **"Every other Monday and Thursday."** Within a week the rule takes the next allowed day; + only when the week runs out does it jump `interval` weeks. A naive `+14 days` alternates + between the two days instead of giving both days of every second week. +* **A stand-up at 09:00** stays at 09:00. + +**Closing a task twice must not spawn twice.** A task can cross into `done` more than once — +close it, reopen it to add a note, close it again — and every crossing reaches the same seam. +`recurrence_spawned_at` is the guard, and it is never cleared: reopening undoes `completed_at`, +but it does not un-emit a successor that already exists and may already have been worked on. + +**Stopping a series keeps the work.** Deleting the rule detaches the tasks it produced rather +than deleting them: they are real work, some of it finished, and a "stop repeating this" +button that swept away three months of completed reports would be the last time anybody +pressed it. + +**Two bugs the live run caught, and reading could not.** + +1. **The weekly CHECK passed the very row it existed to reject.** + `CHECK (freq <> 'weekly' OR array_length(weekdays, 1) >= 1)` looks correct and is not: + `array_length('{}', 1)` returns **NULL**, `NULL >= 1` is NULL, and a CHECK constraint only + *fails* on FALSE. A weekly rule with no weekdays inserted happily. `coalesce(…, 0)` fixes + it, and a test now asserts the coalesce is present because the hermetic suite has no + database to try the expression on. +2. **`_next_number` and `_default_status` were reimplementations**, and one of them invented a + column (`last_number`; the real one is `last_value`). Both were replaced by `core`'s own + `next_task_number` and `load_default_status` — the same mistake WS-27n had just been careful + to avoid, made two tickets later in the same package. + +**A third, caught by its own test:** `int(rule.get("interval") or 1)` turns an explicit `0` +into "every 1" — a typo that looks exactly like a save, and one the database's CHECK would +then have refused as a 500 rather than a 422. Absent now means "every 1"; zero means the +sender made a mistake. + +**In the browser, the sentence is the feature.** A form of five controls is a shape; *"Every 2 +weeks on Mon, Thu, keeping to the schedule"* is something somebody can check before committing +to it — shown live rather than on save, because picking the wrong anchor is invisible until a +cadence has drifted for three months. The occurrence limit reads as what is **left**, not the +cap, and switching frequency clears the fields the new one does not use so a stale +`day_of_month` cannot reappear. diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index 95d42f87..550910ee 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -148,7 +148,7 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`. | WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. | | WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. | | **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT + DEPLOYED** · ✅ **d read half BUILT + DEPLOYED** · ✅ **D2 = d-email BUILT 2026-08-07** (branch `ws-26d-email-timeline`, merged) · 🟢 **d-autolead, d-write dispatchable** · ✅ **D1 = f BUILT 2026-08-07 (branch `ws-26f-pipeline-truth`, NOT run against prod)** · ✅ **D3 = g BUILT 2026-08-07 (branch `ws-26g-reports`, no migration)** · 🟢 **DEMO CRITICAL PATH (owner-directed 2026-08-07, spec §9.0): ~~D1 f~~ (∥ D2 d-email) → ~~D3 g~~ → D4 d-write → D5 d-autolead** · 🟡 **h/i/e deferred past the demo; i spec-thin** | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **migration 144 applied on prod and `/crm` live as of 2026-08-06**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Measured 2026-08-06: mig 145 is applied on prod and the BACKFILL HAS RUN — 737 orgs / 1,189 contacts / 1,516 leads / 551 deals / 1,909 notes, zero dirty rows, zero unmatched owners; the §7.1 pre-flip curl confirmed the tenant honors RFC-1123 `If-Modified-Since` (304). The PUSH direction has still never run: `CRM_ZOHO_SYNC` ships OFF, nothing has ever written the live Zoho tenant, and enabling the flag or hand-running a push cycle against prod stays OWNER-GATE §6.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Deployed:** migrations 144 and 145 are applied on prod as of 2026-08-06 and `/crm` is live, so live rendering, drag persistence and deep links are owner-verifiable now) · **d** integrations — **audited 2026-08-06 GO-NARROWED and the narrowed slice is BUILT** (branch `ws-26d-agent-crm`): `apps/agents/agent-crm/` (`crm-assistant`, MAF, four READ tools over the existing `/crm` routes carrying the caller's `X-User-Email`, read-only enforced at the transport by a GET-only method allowlist) registered in `_KNOWN_AGENTS` + `_AGENT_REGISTRY` + `agent_registry.json`, plus `"crm"` added to the WhatsApp `_KNOWN_SYSTEMS` allowlist **parse-only** (nothing writes `wa_contacts.entity_ref`, the `crm` context block stays `None`, both pinned by test). **The three held-back items are now DISPATCHABLE — their doc blockers (B3/B4/B5/B7) were closed 2026-08-06 in `crm_app.md` §9.1-§9.3, every anchor read off `origin/main` rather than recalled:** **WS-26d-email** (the timeline join is CALLER-scoped, never record-scoped — it reuses the email app's `_account_scope` predicate, copied into `routes/crm/` rather than imported per D-CRM-4, joins by thread not message, inbound `from_address` only, and needs a new address index at the next free migration number) · **WS-26d-autolead** (hook = `routes/email/scheduler_hooks.py::process_new_mail`, the one seam scheduler+manual+webhook all funnel through; the per-message rules loop was considered and REJECTED because a classifier outage there double-fires and history backfills never reach it; unknown-sender test mirrors `_maybe_block_cold`, colleague suppression via `is_own_mail`) · **WS-26d-write** (`request_confirmation` at the top of each tool, fail-closed, no `non_interactive_default="approve"`; `@_annotate_risk` is the shared annotation convention and is NOT enforcement; the Action Broker covers the Zoho push, not the native write — the two fail in OPPOSITE directions and are not interchangeable). The push-queue question is CLOSED — **D-CRM-9 (owner, 2026-08-06): agent-originated writes queue for Zoho exactly like human ones** (🟡 remainder; the flip stays OWNER-GATE) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. **Pipeline blueprint added 2026-08-07 (spec §5.1) after the owner's first live board session found lanes out of order and imported stages at 0% probability — root cause: the importer appends unseen Zoho stages past the seeds at probability 0, and 144's seeds renamed Zoho's defaults so name-match missed them; the admin API that could fix it is headless.** New tickets: **f** pipeline truth + settings UI — **BUILT 2026-08-07** (branch `ws-26f-pipeline-truth`, **no migration**: 144 already carried `position`, `probability`, `type`, `closed_at` and `expected_close_date`). `routes/crm/stage_metadata.py` = `POST /crm/import/zoho/stages`, floor `admin:access:manage`, **dry-run by default** and `?apply=true` to write; **>1 pipeline STOPS before the DB is opened** (D-CRM-11); f4's `closed_at` proxy is a direct UPDATE that bypasses `mark_dirty_on_update`, asserted statically against the statement text AND the module's call graph because the shared fake writes only what a SET clause names. Two settings readers on the Zoho read client with `ZohoScopeError`/`ZohoApiVersionError` so **no-scope, no-data and no-such-endpoint are three different reported outcomes** — and no-scope is the *expected* first answer, since the tenant's refresh token was never minted with `ZohoCRM.settings.*`. D-CRM-10's clamp landed in `admin.py::_validate_status` reading the ROW rather than the payload (a PATCH naming only `type`, or only `probability`, contradicts the rule only in combination with what is stored). `?tab=settings` is the headless-API fix and needs no Zoho token at all. **Nothing has been run against the tenant — dry run included.** · **g** forecast & funnel reports off `crm_status_changes` — **BUILT 2026-08-07** (branch `ws-26g-reports`, **no migration**: 144's `crm_status_changes` already carried every column). `routes/crm/reports.py` = four read-only endpoints (`/crm/reports/{pipeline,funnel,win-loss,owners}`) on the shared gated router, plus `?tab=reports`. **`WEIGHTED_SQL` lifted into `core.py`** (pipeline re-exports it) so the forecast formula has ONE definition, and `core.status_wire` absorbed two duplicate status projections instead of gaining a third. **The cross-language parity mechanism is minted here, not inherited** — the `priority.ts ⟷ priority.py` "precedent" is two hand-kept mirrors joined by a comment with no shared fixture anywhere: `tests/fixtures/crm_weighted_parity.json` (new directory) is read by BOTH pytest (through the emitted SQL, whose expression `_crm_fakes._WEIGHTED_SUM_RE` parses out of the statement text) and vitest (through `board.ts::weightedDeal`/`weightedRows`). ⚠️ **The funnel is defined against what the log RECORDS, not what its name suggests**: `crm_status_changes` logs transitions only, so all 551 imported deals have zero rows and "entered" is a VISITED-SET union (`from_status`, `to_status`, and the deal's current stage) — a `to_status` count reports an empty funnel for the whole live board; dwell is grouped by **`from_status`** (the stage being LEFT, the opposite key from the naive reading); the log stores NAMES, so a renamed lane orphans its history and orphans are REPORTED in `unmatched`, never dropped; and NULL `closed_at` — every imported closed deal until f4's owner-gated backfill runs — falls outside the trailing window, with the count reported so a 0% win rate is explicable rather than mysterious. The lost-reason breakdown carries a NAMED unattributed bucket: the earlier "complete by construction" claim is FALSE, since the importer bypasses both gates and `lost_reason_id` is `ON DELETE SET NULL`. **No `GROUP BY` is emitted** — the ticket asked for the choice to be stated: per-key aggregates in `get_pipeline`'s shape, because the weighted expression binds the lane's own default as `:stage_probability` and a grouped statement would stop BEING the expression the fixture and the fake read. 47 hermetic cases + 20 vitest + the 14-row shared fixture on both sides, 5 mutants red. **Two findings:** the `entity_type='deal'` mutant initially SURVIVED (the funnel keys through deal ids, so a lead row is excluded twice over) — closed with a row stamped `lead` against a DEAL's id, which is realistic precisely because `entity_id` has **no FK**; and `_crm_fakes`' `lower(col) = :param` reader matched a NULL column, which SQL never does, making the unassigned-owner bucket count rows its own aggregate never summed · **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views (🟡 spec-thin, audit-narrow first). **Demo path (2026-08-07, spec §9.0): full chain + all gates intact, tickets re-sequenced not thinned; f gained f4 — imported won/lost deals have no `closed_at` (importer never stamps it), backfilled from Zoho `Closing_Date` as a labeled proxy via a direct UPDATE that MUST bypass dirty-marking or ~500 no-op pushes queue for the live tenant.** | -| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e + i BUILT 2026-08-06 · j + k + l + m + n BUILT 2026-08-07** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g — and n is now BUILT (2026-08-07), so that dependency is SATISFIED**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end. g itself stays 🔴 OWNER-GATE for its own reasons (§6); this changes the prerequisite, not the gate · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store).. **j BUILT 2026-08-07** (mig `152_projects_notifications.sql`, `routes/projects/notifications.py`, the header bell + mention picker; 39 hermetic + 27 vitest cases, 10 mutants red): closes §11.2's second gap — *"assignment is silent"*. **Three rules decide who hears**, each the whole reason for a rule: never the actor (a bell that pings you about your own click gets muted, and a muted bell notifies nobody about anything); never an agent (they are handed work by the WS-27f dispatch sink, so a row addressed to one sits unread forever inflating a badge nobody can clear — enforced in Python AND by a CHECK); and **never somebody who cannot open it**, which is the security property: the notification carries the task's TITLE, so delivering one outside the grant closure leaks it and lands them on a 404. That third rule needed `resolve_visibility_for`, which answers for a THIRD PARTY — `resolve_visibility` reads a `UserContext` and the recipient of a mention has no request in flight — by reading the tables `/auth/me` reads and handing them to the **real** `build_access`, so wildcards and allow/deny overrides resolve identically on both paths. Notifications are written **inside the transaction**, not emitted on the bus: `emit` swallows failures by construction so a broken workflow can never fail a task edit, which is right for agent dispatch and wrong here. A mention is an **address, not a name**, because 148 dropped `UNIQUE(name)` — `@Priya` has no answer. **Two bugs found on the way in, both shipping at the time:** every project-task file upload was answering **422** (`ACTIVITY_TYPES` never learned 150's `attachment`; all 25 attachment tests passed because they monkeypatch `record_activity` — the seam under test was mocked out), fixed with two tests that READ the migrations; and `/projects?task=` did nothing, though the People Center has linked there since WS-28b. **WS-27b's UI BUILT 2026-08-07** (`components/ImportClickUp.tsx`, `lib/importPlan.ts`; 18 vitest cases, 3 mutants red): the importer shipped with WS-27b and was **unreachable from the product** — the empty state named "import a ClickUp workspace" and no control anywhere did it, so a new install stayed empty and the only route to real data was curl. Three steps, only the last of which writes: Preview (`/plan`, reads the tenant) → Dry run (`dry_run:true`, exercises the flattening) → Import. **The mapping stays the owner's act (D-PM-10)** and the UI is built so it stays one: the suggestion is pre-filled and shown beside its confidence IN WORDS ("a guess — check it", not `0.45`, because a bare number invites acceptance without looking), and a CONFIRMED mapping always beats a fresh suggestion so a re-run never silently re-maps a Space somebody already ruled on. Unmapped Spaces are a notice rather than a blocker, matching the importer. ⚠️ The gate is unchanged and is now exactly one click: building this was agent-safe, pressing Import is the owner's act, and no agent has run either endpoint against production. **The Tasks-app mirror path BUILT 2026-08-07** (`routes/projects/import_tasks.py`, `POST /import/from-tasks`; 43 hermetic cases, 7 mutants red) — owner-directed: *"just show up all the data that is there in the Tasks app inside the Projects app"*. A SECOND importer rather than a flag: the ClickUp one needs a live token, spends LLM budget and demands a Center mapping BEFORE anything is written, which is backwards for "show me my work today". This reads `gtd_projects`/`gtd_items` — the mirror already on the box — so no API call, no token, no model spend, and it works when the connector is stale. One named department, with the real ClickUp shape beneath it — **Space → Folder → List** rebuilt as projects, each carrying its own `clickup_id`/`clickup_kind`, so promoting a Space node is how the department split happens later. **Verified against a real Postgres** (full migration set + seeded mirror), which found THREE defects the hermetic suite could not: `gtd_projects.space_id` is LOCAL-only so the Space was always NULL; `pm_projects` has no `clickup_snapshot` column, so the first real click would have 500'd (this shipped in #393 and was fixed before anybody pressed it); and the preview under-counted 4 vs 7 because container nodes were only tallied on the write path. The root IS org-granted (same act as `create_node`, narrower than bulk-granting a tenant). Pinned: nothing outside `pm_*` is written; only `source <> 'LOCAL'` rows are read (a personal capture must not be published to a shared board); the provider's own status names are kept; an orphaned task is COUNTED, not dropped. **Mutation found a real gap**: deleting `dry_run` from the write guards left every test green, because on a FIRST dry run `root_id is None` blocks the write anyway — on a SECOND one the department exists and `dry_run` is the only protection. That is the realistic case (preview → import → preview again) and it now has its own test. **k BUILT 2026-08-07** (`routes/projects/filters.py`, `lib/grouping.ts`, `components/FilterBar.tsx`; 34 hermetic + 24 vitest cases, 13 mutants red, and 23 checks against a REAL Postgres) — closes §11.2's third gap, the one whose name was the sentence *"my open bugs in Ops, grouped by assignee"*. **ONE filter builder serves both the list endpoint and saved views**, because a saved view is nothing but a stored set of these filters and two implementations would drift — a *saved* view showing a different set than the same filters typed by hand is the one thing it may not do, so a test compares the two outputs directly. **Every filter is a WHERE clause**: paging happens in SQL, so a filter applied in Python after `LIMIT` returns short pages, and *"page 2 is empty but there are 40 more"* is a bug people work around for months instead of reporting. **`overdue` means past due AND still open** — a finished task with a past due date is done, and permanent red is how a board teaches people to ignore red. **An unknown category is a 422 naming the five real ones**, not an empty board a client reads as "this project is empty". Unknown config **keys** are DROPPED while a bad **value** falls back, because those are different failures: a view is a preference written by an older client, so refusing one over an unrecognised key would make every deploy a migration of everybody's saved views, whereas rendering still has to produce something. On the board, **a task with two assignees appears in BOTH columns** (it is both people's work; picking one hides it from the other, so the header counts tasks not group sizes), **empty status lanes are kept** while every other grouping drops empties (a missing "In progress" column reads as "no such state", not "nothing in progress"), and **dragging is offered only when the columns are statuses** — a drop writes the field the columns represent, and status is the one that is a plain PATCH, so a card that can be dragged into a column which cannot accept it and snaps back is worse than an honestly static column. `toConfig` is deliberately NOT `toQuery`: a query string carries only text so `toQuery` writes `"true"`, and `fromConfig` refuses a string where a toggle belongs, so a view built from query shape would come back with every toggle silently cleared. The project's **order-bearing board is withheld from the chips** — `tree.py` seeds one `board` view per project and it owns every `pm_view_task_positions` row, so offering its ✕ would offer to delete every hand-arranged position; saved views sit at position 300 above the seeded pair and `orderBearingView` is one function used by both the drag handler and the delete guard. **A FIFTH live bug, found the same way as the previous four:** `due_before` was `CAST(:due_before AS timestamptz)` with the raw query-string value — asyncpg infers the parameter's type FROM that cast and then refuses to encode a `str`, so the query never reached Postgres and **`?due_before=…` answered 500** while the hermetic fake, which agrees with whatever SQL it is handed, stayed green. `parse_when` parses on this side and binds a real `datetime`; garbage is a 422 that says what was expected; a naive value is read as UTC rather than inheriting the connection's TimeZone. Two tests — one on the bound value's TYPE, one refusing any `CAST(:param AS timestamp…)` anywhere in the builder — so the next `after=` filter written the obvious way fails in CI instead of in front of a member. **l BUILT 2026-08-07** (mig `155_projects_custom_fields.sql`, `routes/projects/custom_fields.py`, `lib/customFields.ts` + the panel block and the Fields dialog; 47 hermetic + 36 vitest cases, 23 mutants red, 35 checks against a REAL Postgres) — ClickUp's signature feature, and the shape §5's non-goals already recorded as the additive path: **definitions in a table, values denormalised onto the task as JSONB keyed by `field_key`**. NOT a row per (task, field): that is the textbook EAV answer and costs a join per field on every board paint — five fields across two hundred imported tasks is a thousand rows to gather and re-pivot, per render — whereas the JSONB column arrives with the task for free. **The cost is stated rather than discovered**: a value is not referentially tied to its definition, so the DATABASE cannot stop a key no definition owns from being written, and that guarantee moves into Python. Hence the validation IS the feature: an unknown key is a **422 not a silent drop** (a typo that no-ops looks exactly like a save); a patch **MERGES** (a client that knows three of five fields must not wipe the other two — and an older client, or an automation written before a field existed, is precisely that client); an explicit **null CLEARS the key** rather than storing a null, because it is the only way to express "unset this" and a stored null makes "never filled in" and "deliberately emptied" one value in every filter; and **`true` is not the number 1** — `isinstance(True, int)` is True in Python, so the coercers are one-per-type in a dispatch dict specifically so the boolean check can never drift below the number one. **The deliberate departure from Paca:** its research notes record "deleting a definition does not clean task data" as an accepted cost; it is NOT accepted here, because a key left in the JSONB is invisible — no definition means no column, no form row, no filter — until somebody recreates the name and every old value resurfaces carrying the new meaning. The cleared count is reported (R7/R8). Two things a definition may not change once values exist, both because the stored values would stop meaning what they say: **`field_key` is never editable** (it is the identity every value is filed under) and **`field_type` is a 409 naming the count** (text→select cannot re-interpret what is already written); dropping a select option some task holds is refused the same way, adding one is free, and the UI shows the derived key while the name is still being typed since that is the last moment anybody can change it. **Custom fields are REVERTIBLE, which is what makes them first-class:** `patch_task` folds a custom edit into the SAME `field_change` activity under `custom.` rather than inventing an activity type — `record_activity` refuses a type the CHECK does not list, the trap that made every attachment upload answer 422 — and revert restores by **merging onto what the task holds NOW**, never writing back the whole object, since another field may have been edited since and replacing the blob would silently undo that too. **A bug the ticket's own tests caught before it shipped:** `changedValues` compared a form's boolean against a `null` baseline, so a task with an unanswered checkbox sent `open:false` on EVERY save and posted a timeline entry for an edit nobody made — a checkbox has no "unset" state to render, so `false` is its baseline. **And a fence that was quietly a subset check:** `test_projects_routes` asserted a LIST of mounted paths, which catches the module somebody remembered to add a path for; it now also reads the package directory and asserts every module declaring a `@router` route is imported by `__init__.py` — the C1 trap where a missing import mounts nothing while every direct-call test still passes. Verified by deleting the import and watching it fail. **m BUILT 2026-08-07** (mig `156_projects_tags.sql`, `routes/projects/tags.py`, `lib/tags.ts` + the panel picker, the filter row, a `tag` board axis and the Tags dialog; 31 hermetic + 37 vitest cases, 16 mutants red, 30 checks against a REAL Postgres) — the row the research notes left open ON PURPOSE: `paca_pm_research_2026-08.md` row 13 REFUSED Paca's model (*"a bare jsonb string array on tasks. No registry, no colors, no rename/merge — the weakest part of Paca's model"*) and §5 shipped `pm_tasks.tags TEXT[]` in its place with a registry named as additive later. **The array STAYS** — a join table would add a row per tag per task and a join to every board paint, to buy referential integrity this app enforces in one place, whereas the array arrives with the task and its GIN index (146) already answers "tagged X". What the registry buys: **one spelling per tag** (identity is case-INSENSITIVE via a unique index over `lower(name)` so two racing requests cannot create both, display is case-PRESERVING, and the task's array stores the REGISTRY's form — which is what makes "filter by bug finds all of it" true rather than aspirational, and what lets a rename be one statement); **rename**; **merge**; and a **colour**. **Applying an unregistered tag REGISTERS it** — refusing would make tagging a two-step errand (leave the task, create the tag, come back), which is how tagging gets abandoned, and an abandoned tag set is worse than a messy one. **The cost is stated: every typo becomes a tag** — which is exactly why merge is here and is not optional, and why the picker SHOWS the moment of creation rather than minting silently. **A rename onto an existing name is a 409, not a silent merge**: different operations, different outcomes, and one of them destroys a tag — quietly doing the destructive one because the names collided is what stops people using a rename button. **A task carrying BOTH tags ends a merge with the target ONCE** — the case that is easy to get wrong, and getting it wrong leaves a duplicate that renders twice and survives the next merge too; `merged_tags` is pure so that case is asserted directly, and the rewrite runs over affected rows in Python rather than as an `array_replace`, which would leave the duplicate. **TWO tag filters** because both questions get asked and neither answers the other: `tags` is ANY (`&&`) and `tags_all` is ALL (`@>`); collapsing them would silently pick a meaning, and with three tags the answers differ by almost everything. On the board a task with three tags appears in three columns, the same honesty as two assignees appearing in both theirs. **The migration backfills AND rewrites data, deliberately and narrowly:** `tags` has been on `pm_tasks` since 146 and the import path writes them, so an empty registry beside a tagged corpus means the first rename finds nothing; the winning display form is **the spelling people actually use** (most frequent, ties broken deterministically — `min()` alone would canonicalise 400 "Bug" to a single stray "BUG"), and task arrays are then made to agree, only ever swapping one CASING of a tag for another, with the count reported in a NOTICE. **A bug the live run caught in that block:** the canonicalisation used the implicit-comma `FROM pm_tasks t, unnest(t.tags) ... LEFT JOIN` form, where the LEFT JOIN binds only to `unnest(...)` and `t` is not in scope for its ON clause — the migration aborted with "invalid reference to FROM-clause entry for table t". Rewritten as `CROSS JOIN LATERAL … WITH ORDINALITY`, which also fixed a second problem the first version would have shipped: `array_agg(DISTINCT ...)` sorts by its own expression, so every task's tag list would have come back alphabetised. **n BUILT 2026-08-07** (`routes/projects/bulk.py` → `POST /projects/tasks/bulk`, `lib/selection.ts`, `components/BulkBar.tsx` + checkboxes on board and list; 35 hermetic + 32 vitest cases, 16 mutants red, 34 checks against a REAL Postgres; **no migration**) — **the ticket §11.3 names as gating g, so that prerequisite is now satisfied.** It **reuses `automation.apply_task_patch` rather than growing a second writer**: that service exists because WS-27f needed an edit indistinguishable in validation from a human PATCH, and a bulk endpoint with its own field handling would be a third opinion about what a task edit is. Tags go through the same registry the panel uses, because the registry cannot be true if bulk is a second door into the array. **Status is named, never keyed — load-bearing here rather than stylistic**: a selection spans projects and a status id belongs to one root, so `status_id` for fifty tasks across three projects puts two thirds of them in a lane that is not theirs; it is a 422 with its OWN message, because it is the mistake somebody makes by copying a single-task PATCH body and "unknown field" would not explain why the thing that works on one task is refused on fifty. **Assignees and tags are ADD/REMOVE, never SET** — "assign these to Priya" means ALSO Priya, and a replace wipes every individual assignment the fifty tasks already carried; the destructive spelling is absent rather than discouraged. **Shape validated once, outcomes per task**, because those are different failures: an unsettable field is the same mistake for all fifty (422 before any write), while a status name present in one project and absent from another is a fact about THAT task — failing the batch for it makes a mixed selection unusable, which is precisely the selection somebody makes after an import. **An invisible task is SKIPPED, not an error** (R5: per-id reporting says exactly what a per-id 404 says, and aborting would let a caller probe for existence). **One transaction** — a re-triage that half happened is harder to recover from than one that did not, because nobody can tell which half. **Re-asserting a value is not a change** (`moved_people` is pure so the claim is asserted directly): fifty tasks already Priya's would each gain a timeline entry saying nothing, and a count nobody can trust is worse than no count. **ONE notification per person per batch, not one per task** — being handed fifty tasks should ring once and say fifty; fifty bells is a bell people turn off, which is WS-27j's own argument applied to the case that would have broken it. In the browser the selection is **pruned whenever the filter changes** (select forty, narrow to three, press Done must not act on thirty-seven nobody can see), a shift-click ranges over the board's ON-SCREEN order, a two-assignee card drawn in two columns counts once, and the outcome line names every category including the boring ones. **AND IT UNCOVERED A SHIPPED WS-27j BUG** (spec §11.12): `notifications.deliverable` probed only `project_clause('t.root_project_id')` while `core.task_visibility_clause` — whose docstring warns a second implementation "would drift the moment one is edited alone" — carries TWO ways in. So (1) anybody assigned work in a project they hold no grant on was judged undeliverable: they could OPEN the task, the assignment notified nobody, and the response told the assigner they could not see it — the silent assignment WS-27j exists to end, still open for the most common case in a grant-scoped app; and (2) scoping to `root_project_id` ALSO missed a grant made on a SUBPROJECT — the old test asserted that scoping on the argument that "probing `project_id` would miss a grant made on an ancestor", which is BACKWARDS, as a real-Postgres run showed: the closure is recursive and expands DOWNWARD. Fixed to use the shared clause; the test that encoded the wrong reasoning now records why it was wrong. **A fake collision the fix exposed:** `FakeDB` matched the rule-3 probe by substring, and the new clause embeds both `pm_task_assignees` and the closure's `UNION` — exactly the AUDIENCE branch's fingerprint — so `deliverable` got a list of assignees where it expected a visibility answer and four tests failed for a reason unrelated to the code under test. A fake that dispatches on substrings needs fingerprints that are SPECIFIC, not merely present | +| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e + i BUILT 2026-08-06 · j + k + l + m + n + o BUILT 2026-08-07** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g — and n is now BUILT (2026-08-07), so that dependency is SATISFIED**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end. g itself stays 🔴 OWNER-GATE for its own reasons (§6); this changes the prerequisite, not the gate · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store).. **j BUILT 2026-08-07** (mig `152_projects_notifications.sql`, `routes/projects/notifications.py`, the header bell + mention picker; 39 hermetic + 27 vitest cases, 10 mutants red): closes §11.2's second gap — *"assignment is silent"*. **Three rules decide who hears**, each the whole reason for a rule: never the actor (a bell that pings you about your own click gets muted, and a muted bell notifies nobody about anything); never an agent (they are handed work by the WS-27f dispatch sink, so a row addressed to one sits unread forever inflating a badge nobody can clear — enforced in Python AND by a CHECK); and **never somebody who cannot open it**, which is the security property: the notification carries the task's TITLE, so delivering one outside the grant closure leaks it and lands them on a 404. That third rule needed `resolve_visibility_for`, which answers for a THIRD PARTY — `resolve_visibility` reads a `UserContext` and the recipient of a mention has no request in flight — by reading the tables `/auth/me` reads and handing them to the **real** `build_access`, so wildcards and allow/deny overrides resolve identically on both paths. Notifications are written **inside the transaction**, not emitted on the bus: `emit` swallows failures by construction so a broken workflow can never fail a task edit, which is right for agent dispatch and wrong here. A mention is an **address, not a name**, because 148 dropped `UNIQUE(name)` — `@Priya` has no answer. **Two bugs found on the way in, both shipping at the time:** every project-task file upload was answering **422** (`ACTIVITY_TYPES` never learned 150's `attachment`; all 25 attachment tests passed because they monkeypatch `record_activity` — the seam under test was mocked out), fixed with two tests that READ the migrations; and `/projects?task=` did nothing, though the People Center has linked there since WS-28b. **WS-27b's UI BUILT 2026-08-07** (`components/ImportClickUp.tsx`, `lib/importPlan.ts`; 18 vitest cases, 3 mutants red): the importer shipped with WS-27b and was **unreachable from the product** — the empty state named "import a ClickUp workspace" and no control anywhere did it, so a new install stayed empty and the only route to real data was curl. Three steps, only the last of which writes: Preview (`/plan`, reads the tenant) → Dry run (`dry_run:true`, exercises the flattening) → Import. **The mapping stays the owner's act (D-PM-10)** and the UI is built so it stays one: the suggestion is pre-filled and shown beside its confidence IN WORDS ("a guess — check it", not `0.45`, because a bare number invites acceptance without looking), and a CONFIRMED mapping always beats a fresh suggestion so a re-run never silently re-maps a Space somebody already ruled on. Unmapped Spaces are a notice rather than a blocker, matching the importer. ⚠️ The gate is unchanged and is now exactly one click: building this was agent-safe, pressing Import is the owner's act, and no agent has run either endpoint against production. **The Tasks-app mirror path BUILT 2026-08-07** (`routes/projects/import_tasks.py`, `POST /import/from-tasks`; 43 hermetic cases, 7 mutants red) — owner-directed: *"just show up all the data that is there in the Tasks app inside the Projects app"*. A SECOND importer rather than a flag: the ClickUp one needs a live token, spends LLM budget and demands a Center mapping BEFORE anything is written, which is backwards for "show me my work today". This reads `gtd_projects`/`gtd_items` — the mirror already on the box — so no API call, no token, no model spend, and it works when the connector is stale. One named department, with the real ClickUp shape beneath it — **Space → Folder → List** rebuilt as projects, each carrying its own `clickup_id`/`clickup_kind`, so promoting a Space node is how the department split happens later. **Verified against a real Postgres** (full migration set + seeded mirror), which found THREE defects the hermetic suite could not: `gtd_projects.space_id` is LOCAL-only so the Space was always NULL; `pm_projects` has no `clickup_snapshot` column, so the first real click would have 500'd (this shipped in #393 and was fixed before anybody pressed it); and the preview under-counted 4 vs 7 because container nodes were only tallied on the write path. The root IS org-granted (same act as `create_node`, narrower than bulk-granting a tenant). Pinned: nothing outside `pm_*` is written; only `source <> 'LOCAL'` rows are read (a personal capture must not be published to a shared board); the provider's own status names are kept; an orphaned task is COUNTED, not dropped. **Mutation found a real gap**: deleting `dry_run` from the write guards left every test green, because on a FIRST dry run `root_id is None` blocks the write anyway — on a SECOND one the department exists and `dry_run` is the only protection. That is the realistic case (preview → import → preview again) and it now has its own test. **k BUILT 2026-08-07** (`routes/projects/filters.py`, `lib/grouping.ts`, `components/FilterBar.tsx`; 34 hermetic + 24 vitest cases, 13 mutants red, and 23 checks against a REAL Postgres) — closes §11.2's third gap, the one whose name was the sentence *"my open bugs in Ops, grouped by assignee"*. **ONE filter builder serves both the list endpoint and saved views**, because a saved view is nothing but a stored set of these filters and two implementations would drift — a *saved* view showing a different set than the same filters typed by hand is the one thing it may not do, so a test compares the two outputs directly. **Every filter is a WHERE clause**: paging happens in SQL, so a filter applied in Python after `LIMIT` returns short pages, and *"page 2 is empty but there are 40 more"* is a bug people work around for months instead of reporting. **`overdue` means past due AND still open** — a finished task with a past due date is done, and permanent red is how a board teaches people to ignore red. **An unknown category is a 422 naming the five real ones**, not an empty board a client reads as "this project is empty". Unknown config **keys** are DROPPED while a bad **value** falls back, because those are different failures: a view is a preference written by an older client, so refusing one over an unrecognised key would make every deploy a migration of everybody's saved views, whereas rendering still has to produce something. On the board, **a task with two assignees appears in BOTH columns** (it is both people's work; picking one hides it from the other, so the header counts tasks not group sizes), **empty status lanes are kept** while every other grouping drops empties (a missing "In progress" column reads as "no such state", not "nothing in progress"), and **dragging is offered only when the columns are statuses** — a drop writes the field the columns represent, and status is the one that is a plain PATCH, so a card that can be dragged into a column which cannot accept it and snaps back is worse than an honestly static column. `toConfig` is deliberately NOT `toQuery`: a query string carries only text so `toQuery` writes `"true"`, and `fromConfig` refuses a string where a toggle belongs, so a view built from query shape would come back with every toggle silently cleared. The project's **order-bearing board is withheld from the chips** — `tree.py` seeds one `board` view per project and it owns every `pm_view_task_positions` row, so offering its ✕ would offer to delete every hand-arranged position; saved views sit at position 300 above the seeded pair and `orderBearingView` is one function used by both the drag handler and the delete guard. **A FIFTH live bug, found the same way as the previous four:** `due_before` was `CAST(:due_before AS timestamptz)` with the raw query-string value — asyncpg infers the parameter's type FROM that cast and then refuses to encode a `str`, so the query never reached Postgres and **`?due_before=…` answered 500** while the hermetic fake, which agrees with whatever SQL it is handed, stayed green. `parse_when` parses on this side and binds a real `datetime`; garbage is a 422 that says what was expected; a naive value is read as UTC rather than inheriting the connection's TimeZone. Two tests — one on the bound value's TYPE, one refusing any `CAST(:param AS timestamp…)` anywhere in the builder — so the next `after=` filter written the obvious way fails in CI instead of in front of a member. **l BUILT 2026-08-07** (mig `155_projects_custom_fields.sql`, `routes/projects/custom_fields.py`, `lib/customFields.ts` + the panel block and the Fields dialog; 47 hermetic + 36 vitest cases, 23 mutants red, 35 checks against a REAL Postgres) — ClickUp's signature feature, and the shape §5's non-goals already recorded as the additive path: **definitions in a table, values denormalised onto the task as JSONB keyed by `field_key`**. NOT a row per (task, field): that is the textbook EAV answer and costs a join per field on every board paint — five fields across two hundred imported tasks is a thousand rows to gather and re-pivot, per render — whereas the JSONB column arrives with the task for free. **The cost is stated rather than discovered**: a value is not referentially tied to its definition, so the DATABASE cannot stop a key no definition owns from being written, and that guarantee moves into Python. Hence the validation IS the feature: an unknown key is a **422 not a silent drop** (a typo that no-ops looks exactly like a save); a patch **MERGES** (a client that knows three of five fields must not wipe the other two — and an older client, or an automation written before a field existed, is precisely that client); an explicit **null CLEARS the key** rather than storing a null, because it is the only way to express "unset this" and a stored null makes "never filled in" and "deliberately emptied" one value in every filter; and **`true` is not the number 1** — `isinstance(True, int)` is True in Python, so the coercers are one-per-type in a dispatch dict specifically so the boolean check can never drift below the number one. **The deliberate departure from Paca:** its research notes record "deleting a definition does not clean task data" as an accepted cost; it is NOT accepted here, because a key left in the JSONB is invisible — no definition means no column, no form row, no filter — until somebody recreates the name and every old value resurfaces carrying the new meaning. The cleared count is reported (R7/R8). Two things a definition may not change once values exist, both because the stored values would stop meaning what they say: **`field_key` is never editable** (it is the identity every value is filed under) and **`field_type` is a 409 naming the count** (text→select cannot re-interpret what is already written); dropping a select option some task holds is refused the same way, adding one is free, and the UI shows the derived key while the name is still being typed since that is the last moment anybody can change it. **Custom fields are REVERTIBLE, which is what makes them first-class:** `patch_task` folds a custom edit into the SAME `field_change` activity under `custom.` rather than inventing an activity type — `record_activity` refuses a type the CHECK does not list, the trap that made every attachment upload answer 422 — and revert restores by **merging onto what the task holds NOW**, never writing back the whole object, since another field may have been edited since and replacing the blob would silently undo that too. **A bug the ticket's own tests caught before it shipped:** `changedValues` compared a form's boolean against a `null` baseline, so a task with an unanswered checkbox sent `open:false` on EVERY save and posted a timeline entry for an edit nobody made — a checkbox has no "unset" state to render, so `false` is its baseline. **And a fence that was quietly a subset check:** `test_projects_routes` asserted a LIST of mounted paths, which catches the module somebody remembered to add a path for; it now also reads the package directory and asserts every module declaring a `@router` route is imported by `__init__.py` — the C1 trap where a missing import mounts nothing while every direct-call test still passes. Verified by deleting the import and watching it fail. **m BUILT 2026-08-07** (mig `156_projects_tags.sql`, `routes/projects/tags.py`, `lib/tags.ts` + the panel picker, the filter row, a `tag` board axis and the Tags dialog; 31 hermetic + 37 vitest cases, 16 mutants red, 30 checks against a REAL Postgres) — the row the research notes left open ON PURPOSE: `paca_pm_research_2026-08.md` row 13 REFUSED Paca's model (*"a bare jsonb string array on tasks. No registry, no colors, no rename/merge — the weakest part of Paca's model"*) and §5 shipped `pm_tasks.tags TEXT[]` in its place with a registry named as additive later. **The array STAYS** — a join table would add a row per tag per task and a join to every board paint, to buy referential integrity this app enforces in one place, whereas the array arrives with the task and its GIN index (146) already answers "tagged X". What the registry buys: **one spelling per tag** (identity is case-INSENSITIVE via a unique index over `lower(name)` so two racing requests cannot create both, display is case-PRESERVING, and the task's array stores the REGISTRY's form — which is what makes "filter by bug finds all of it" true rather than aspirational, and what lets a rename be one statement); **rename**; **merge**; and a **colour**. **Applying an unregistered tag REGISTERS it** — refusing would make tagging a two-step errand (leave the task, create the tag, come back), which is how tagging gets abandoned, and an abandoned tag set is worse than a messy one. **The cost is stated: every typo becomes a tag** — which is exactly why merge is here and is not optional, and why the picker SHOWS the moment of creation rather than minting silently. **A rename onto an existing name is a 409, not a silent merge**: different operations, different outcomes, and one of them destroys a tag — quietly doing the destructive one because the names collided is what stops people using a rename button. **A task carrying BOTH tags ends a merge with the target ONCE** — the case that is easy to get wrong, and getting it wrong leaves a duplicate that renders twice and survives the next merge too; `merged_tags` is pure so that case is asserted directly, and the rewrite runs over affected rows in Python rather than as an `array_replace`, which would leave the duplicate. **TWO tag filters** because both questions get asked and neither answers the other: `tags` is ANY (`&&`) and `tags_all` is ALL (`@>`); collapsing them would silently pick a meaning, and with three tags the answers differ by almost everything. On the board a task with three tags appears in three columns, the same honesty as two assignees appearing in both theirs. **The migration backfills AND rewrites data, deliberately and narrowly:** `tags` has been on `pm_tasks` since 146 and the import path writes them, so an empty registry beside a tagged corpus means the first rename finds nothing; the winning display form is **the spelling people actually use** (most frequent, ties broken deterministically — `min()` alone would canonicalise 400 "Bug" to a single stray "BUG"), and task arrays are then made to agree, only ever swapping one CASING of a tag for another, with the count reported in a NOTICE. **A bug the live run caught in that block:** the canonicalisation used the implicit-comma `FROM pm_tasks t, unnest(t.tags) ... LEFT JOIN` form, where the LEFT JOIN binds only to `unnest(...)` and `t` is not in scope for its ON clause — the migration aborted with "invalid reference to FROM-clause entry for table t". Rewritten as `CROSS JOIN LATERAL … WITH ORDINALITY`, which also fixed a second problem the first version would have shipped: `array_agg(DISTINCT ...)` sorts by its own expression, so every task's tag list would have come back alphabetised. **n BUILT 2026-08-07** (`routes/projects/bulk.py` → `POST /projects/tasks/bulk`, `lib/selection.ts`, `components/BulkBar.tsx` + checkboxes on board and list; 35 hermetic + 32 vitest cases, 16 mutants red, 34 checks against a REAL Postgres; **no migration**) — **the ticket §11.3 names as gating g, so that prerequisite is now satisfied.** It **reuses `automation.apply_task_patch` rather than growing a second writer**: that service exists because WS-27f needed an edit indistinguishable in validation from a human PATCH, and a bulk endpoint with its own field handling would be a third opinion about what a task edit is. Tags go through the same registry the panel uses, because the registry cannot be true if bulk is a second door into the array. **Status is named, never keyed — load-bearing here rather than stylistic**: a selection spans projects and a status id belongs to one root, so `status_id` for fifty tasks across three projects puts two thirds of them in a lane that is not theirs; it is a 422 with its OWN message, because it is the mistake somebody makes by copying a single-task PATCH body and "unknown field" would not explain why the thing that works on one task is refused on fifty. **Assignees and tags are ADD/REMOVE, never SET** — "assign these to Priya" means ALSO Priya, and a replace wipes every individual assignment the fifty tasks already carried; the destructive spelling is absent rather than discouraged. **Shape validated once, outcomes per task**, because those are different failures: an unsettable field is the same mistake for all fifty (422 before any write), while a status name present in one project and absent from another is a fact about THAT task — failing the batch for it makes a mixed selection unusable, which is precisely the selection somebody makes after an import. **An invisible task is SKIPPED, not an error** (R5: per-id reporting says exactly what a per-id 404 says, and aborting would let a caller probe for existence). **One transaction** — a re-triage that half happened is harder to recover from than one that did not, because nobody can tell which half. **Re-asserting a value is not a change** (`moved_people` is pure so the claim is asserted directly): fifty tasks already Priya's would each gain a timeline entry saying nothing, and a count nobody can trust is worse than no count. **ONE notification per person per batch, not one per task** — being handed fifty tasks should ring once and say fifty; fifty bells is a bell people turn off, which is WS-27j's own argument applied to the case that would have broken it. In the browser the selection is **pruned whenever the filter changes** (select forty, narrow to three, press Done must not act on thirty-seven nobody can see), a shift-click ranges over the board's ON-SCREEN order, a two-assignee card drawn in two columns counts once, and the outcome line names every category including the boring ones. **AND IT UNCOVERED A SHIPPED WS-27j BUG** (spec §11.12): `notifications.deliverable` probed only `project_clause('t.root_project_id')` while `core.task_visibility_clause` — whose docstring warns a second implementation "would drift the moment one is edited alone" — carries TWO ways in. So (1) anybody assigned work in a project they hold no grant on was judged undeliverable: they could OPEN the task, the assignment notified nobody, and the response told the assigner they could not see it — the silent assignment WS-27j exists to end, still open for the most common case in a grant-scoped app; and (2) scoping to `root_project_id` ALSO missed a grant made on a SUBPROJECT — the old test asserted that scoping on the argument that "probing `project_id` would miss a grant made on an ancestor", which is BACKWARDS, as a real-Postgres run showed: the closure is recursive and expands DOWNWARD. Fixed to use the shared clause; the test that encoded the wrong reasoning now records why it was wrong. **A fake collision the fix exposed:** `FakeDB` matched the rule-3 probe by substring, and the new clause embeds both `pm_task_assignees` and the closure's `UNION` — exactly the AUDIENCE branch's fingerprint — so `deliverable` got a list of assignees where it expected a visibility answer and four tests failed for a reason unrelated to the code under test. A fake that dispatches on substrings needs fingerprints that are SPECIFIC, not merely present. **o BUILT 2026-08-07** (mig `157_projects_recurrence.sql`, `routes/projects/recurrence.py`, `lib/recurrence.ts` + the repeat row in the task panel; 45 hermetic + 27 vitest cases, 31 mutants red, 39 checks against a REAL Postgres) — **NO SCHEDULER, and that is FORCED rather than chosen**: §5's non-goals say `/workflows` is the only engine (ADR-028/D6), so a recurrence worker here would be exactly the second engine the spec forbids. The successor is created **when a task CLOSES** — `apply_status_transition` already owns that moment, so a task finished from the board, from My work, from an automation or from a bulk edit all recur identically, and a second call site would be a fifth way to finish a task that forgets to. **The cost is stated:** a series only advances when somebody finishes the current one — a monthly report nobody closes does not pile up twelve copies (right), but a daily standup nobody ticks does not appear tomorrow (the honest limitation); materialising ahead is already reachable through the engine that owns scheduling (cron trigger + the `pm_task` node WS-27f added), so nothing needs undoing. **The anchor is PER RULE because the two answers mean different things**: `due` keeps the schedule ("stock count on the 1st" stays on the 1st however late the last was closed, so the series does not drift) and `completed` measures from when the work was actually done ("water the plants every 3 days" restarts when you water them). A `due` anchor also **catches up** — a monthly task closed six weeks late would otherwise produce a successor already overdue the moment it appeared — and the missed occurrences are SKIPPED rather than backfilled, because nobody wants four copies of a standup they did not attend. **The date arithmetic is where this is either right or quietly wrong for a year**, so it is pure and each case is one assertion: January 31st monthly (clamped at COMPUTATION time and stored as asked — storing the clamp permanently demotes the rule to the 28th after its first February), February 29th yearly, "every other Mon and Thu" (within a week it takes the next allowed day and only jumps `interval` weeks when the week runs out; a naive `+14 days` alternates between the two days instead of giving both days of every second week), and a 09:00 standup staying at 09:00. **Closing twice must not spawn twice** — a task can cross into `done` repeatedly (close, reopen to add a note, close again) and every crossing hits the same seam, so `recurrence_spawned_at` guards it and is NEVER cleared: reopening undoes `completed_at` but does not un-emit a successor that may already have been worked on. **Stopping a series keeps the work** (detach, not delete): they are real tasks, some finished, and a button that swept away three months of completed reports is one nobody presses twice. **TWO BUGS THE LIVE RUN CAUGHT AND READING COULD NOT:** (1) the weekly CHECK passed the very row it existed to reject — `array_length('{}', 1)` returns **NULL**, `NULL >= 1` is NULL, and a CHECK only FAILS on false, so a weekly rule with no weekdays inserted happily past a constraint that looked correct; `coalesce(…, 0)` fixes it and a test asserts the coalesce is present, since the hermetic suite has no database to try the expression on; and (2) `_next_number`/`_default_status` were reimplementations, one of which invented a column (`last_number`; the real one is `last_value`) — replaced by `core`'s own `next_task_number` and `load_default_status`, which is the same mistake WS-27n had just been careful to avoid, made two tickets later in the same package. **A third, caught by its own test:** `int(rule.get("interval") or 1)` turns an explicit `0` into "every 1" — a typo that looks exactly like a save, and one the DB CHECK would then have refused as a 500 rather than a 422. In the browser **the SENTENCE is the feature** — a form of five controls is a shape, whereas "Every 2 weeks on Mon, Thu, keeping to the schedule" is something somebody can check before committing, shown LIVE rather than on save because picking the wrong anchor is invisible until a cadence has drifted for three months; the occurrence limit reads as what is LEFT not the cap, and switching frequency clears the fields the new one does not use so a stale `day_of_month` cannot reappear | | **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | ✅ **a + b BUILT 2026-08-06 · b-write BUILT 2026-08-07** · 🟢 c–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE).. **a BUILT 2026-08-06** (mig `148_people_key_shape.sql` + `scripts/import_hr_people.py`; 22 cases, 11 mutants red, 1 equivalent): `UNIQUE(name)` dropped, partial unique on `lower(email)`, status CHECK. **P-1 did not name its own consequence** — the HR importer upserts `ON CONFLICT (name)` and would have failed outright, so a `source_key` (`:`) carries the upsert instead, backfilled BEFORE the constraint is dropped while `name` is still distinct. **Neither new constraint may block a deploy** (main was bitten twice this month): a duplicate address is quarantined into a new `email_conflict` column with a deterministic winner rather than failing `CREATE UNIQUE INDEX`, and the status CHECK is added `NOT VALID` then validated in a guarded block, so an unanticipated legacy value leaves a NOTICE instead of stopping the deploy. ⚠️ `schema.generated.sql` NOT refreshed — needs a live DB; regenerate on the first deploy that applies 148. **b BUILT 2026-08-06** (mig `149_people.sql`, `routes/people/`, `src/app/people/`; 32 hermetic + 28 vitest cases, 11 mutants red): `/people` directory, person page with all four panels, and the People Center's "Directory & org chart" sub-app flipped live — closing WS-13's outstanding read view. **Its own feature slug**, not `feature:tasks`: a manager who needs the org chart should not be handed the personal GTD task manager to get it. The gate is new but the HR **projection is imported** from `tasks.core` and a test asserts the function's *identity*, since two answers to "may this caller see skills" are two answers waiting to drift. Three filters (the `q` skills clause, `skill`, `has_capacity`) are dropped without `admin:members:read` so search cannot become an oracle for the hidden field — and the response carries `hr_visible` so the UI says "restricted" rather than leaving a blank strip to read as "nobody filled it in". Load is **computed from open assigned tasks** and carries `unestimated`, because a bar built from the estimate sum alone shows somebody holding thirty un-estimated tasks as completely free. The work panel is scoped by the **viewer's** grants and answers `available:false` without `feature:projects`. **Registration is FIVE places, not four** — the fifth is `test_org_access_enforcement.GATED_ROUTERS`, hand-maintained, where an absent router is unchecked rather than passing; also added the named `test_projects_is_registered_on_both_sides` that WS-27a never wrote. **b-write BUILT 2026-08-07** (`people/components/PersonEditor.tsx`, `people/lib/form.ts`, `people/lib/write.ts`; 26 hermetic + 23 vitest cases, 7 mutants red): closes the regression the 2026-08-06 scope narrowing opened — deleting the tasks app's People view took `PersonEditor` with it, and with it the only UI for creating a person, editing skills and uploading a résumé. The GET-only `/api/people` proxy is **unchanged**: the writes go to `/api/tasks/people`, where they have always lived. Controls are absent rather than disabled, driven by a new **`can_manage`** flag on the reads — hide-rather-than-disable is impossible unless the read tells the UI, and the alternative is drawing the button and letting the click find a 403. **Restoring it turned up three ways migration 148 had already broken the write routes**, each a 500 in front of an admin rather than a test failure: the status vocabulary moved (49's `inactive`/`on_leave` vs 148's CHECK) and is now ONE tuple shared by filter, facets, select and validation; `create_person` still refused a duplicate NAME, preserving precisely the behaviour 148 dropped `UNIQUE(name)` to remove; and nothing checked the address 148 made unique, so a duplicate was an `IntegrityError` instead of a 409 naming the other row. **The lesson recorded in spec §10:** a migration that changes a table's shape has to be walked against every route that WRITES it — the read routes were built after 148 and were correct by construction, the write routes predated it and were never revisited | --- diff --git a/apps/services/gateway/gateway/routes/projects/__init__.py b/apps/services/gateway/gateway/routes/projects/__init__.py index c1a818cb..9a53a4c0 100644 --- a/apps/services/gateway/gateway/routes/projects/__init__.py +++ b/apps/services/gateway/gateway/routes/projects/__init__.py @@ -31,6 +31,7 @@ from gateway.routes.projects import me as _me # noqa: F401 from gateway.routes.projects import notifications as _notifications # noqa: F401 from gateway.routes.projects import personal as _personal # noqa: F401 +from gateway.routes.projects import recurrence as _recurrence # noqa: F401 from gateway.routes.projects import tags as _tags # noqa: F401 from gateway.routes.projects import tasks as _tasks # noqa: F401 from gateway.routes.projects import tree as _tree # noqa: F401 diff --git a/apps/services/gateway/gateway/routes/projects/core.py b/apps/services/gateway/gateway/routes/projects/core.py index d0fa98eb..f7f76867 100644 --- a/apps/services/gateway/gateway/routes/projects/core.py +++ b/apps/services/gateway/gateway/routes/projects/core.py @@ -942,7 +942,25 @@ async def apply_status_transition( "to_category": new_status.category, }, ) - return {"row": row, "from": old_status, "to": new_status} + + # WS-27o — a task crossing INTO a closing category is what advances a + # recurring series. Done here rather than in each caller because this is the + # one place that knows the crossing happened: the board, My work, an + # automation and a bulk edit all arrive through this helper, and a second + # call site would be a fifth way to finish a task that forgets to recur. + # + # Imported inside the function so `core` — the leaf every feature module + # imports — gains no dependency on one of them. + successor: str | None = None + if is_closed and not was_closed: + from gateway.routes.projects.recurrence import spawn_successor + + successor = await spawn_successor(db, row, actor_id=created_by) + + return { + "row": row, "from": old_status, "to": new_status, + "recurred_to": successor, + } # ── The activity spine ────────────────────────────────────────────────────── diff --git a/apps/services/gateway/gateway/routes/projects/recurrence.py b/apps/services/gateway/gateway/routes/projects/recurrence.py new file mode 100644 index 00000000..a76a28cf --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/recurrence.py @@ -0,0 +1,487 @@ +"""Projects · recurring tasks (WS-27o). + +Spec: ``ai-company-brain/specs/project_management_app.md`` §11.2 item 7, §11.13. + + GET /projects/tasks/{task_id}/recurrence + PUT /projects/tasks/{task_id}/recurrence → set or replace the rule + DELETE /projects/tasks/{task_id}/recurrence → stop the series + +*"Every operations cadence is recurring. Without it those live in someone's head +or in ClickUp."* + +**No scheduler, and that is forced rather than chosen.** §5's non-goals: *"A +second automation engine. ADR-028/D6: `/workflows` is the only engine."* A +recurrence worker here would be exactly that. So the successor is created when a +task **closes** — `apply_status_transition` already owns that moment — and the +feature needs no cron, no worker, no new transport. Migration 157 records what +that costs. + +**The date arithmetic is pure and lives at the top of this file.** Recurrence is +one of those features that looks trivial and is not: January 31st monthly, a +weekly rule spanning a Sunday, a task closed six weeks late, and a February 29th +yearly rule are each a different way to be quietly wrong for a year. +""" + +from __future__ import annotations + +import calendar +from datetime import UTC, datetime, timedelta +from typing import Any + +from acb_auth import UserContext, get_current_user +from fastapi import Depends, HTTPException +from gateway.routes.projects.core import ( + _get_db, + actor, + clean_payload, + insert_row, + load_default_status, + load_visible_task, + next_task_number, + record_activity, + resolve_visibility, + router, + update_row, +) +from pydantic import BaseModel +from sqlalchemy import text + +FREQS: tuple[str, ...] = ("daily", "weekly", "monthly", "yearly") +ANCHORS: tuple[str, ...] = ("due", "completed") +MAX_INTERVAL = 365 + +#: How many times the catch-up loop may advance before giving up. +#: +#: A rule anchored on `due` advances until it lands in the future, so a daily +#: task last due five years ago is ~1800 steps. The bound exists because the +#: alternative to a cap is an unbounded loop on data somebody can create, and a +#: series that far behind is dead rather than late. +MAX_CATCHUP = 4000 + +#: Fields carried from a finished task to its successor. +#: +#: NOT here, deliberately: `status_id` (the successor starts in the project's +#: default lane, because "this month's report" has not been started), +#: `completed_at`, `task_number` (allocated fresh), and the timeline — comments +#: and attachments belong to the occurrence they were made on, and copying last +#: month's discussion onto this month's task is how a recurring task becomes +#: unreadable by March. +CARRIED_FIELDS: tuple[str, ...] = ( + "project_id", "root_project_id", "parent_task_id", "type_id", "title", + "description", "importance", "estimate_mins", "tags", "custom_fields", + "source", +) + + +def _clamp_day(year: int, month: int, day: int) -> int: + """The requested day-of-month, or the last day the month actually has. + + **The January 31st case**, and the reason `day_of_month` stores what + somebody asked for rather than what February can deliver: clamping at + computation time keeps "the 31st" meaning the 31st in the months that have + one. Storing the clamped value instead would silently and permanently + demote a monthly rule to the 28th after its first February. + """ + return min(day, calendar.monthrange(year, month)[1]) + + +def _add_months(when: datetime, months: int, day_of_month: int) -> datetime: + total = (when.year * 12 + (when.month - 1)) + months + year, month = divmod(total, 12) + month += 1 + return when.replace( + year=year, month=month, day=_clamp_day(year, month, day_of_month), + ) + + +def _next_weekday(after: datetime, weekdays: list[int], interval: int) -> datetime: + """The next allowed weekday strictly after ``after``. + + ISO weekdays: Monday is 1. Within the same week the next allowed day is + simply the next one up; when the week runs out the rule jumps ``interval`` + weeks and takes the first allowed day of that week — which is what makes + "every other Monday and Thursday" land on both days of the right weeks + rather than alternating between them. + """ + allowed = sorted(set(weekdays)) + current = after.isoweekday() + later = [d for d in allowed if d > current] + if later: + return after + timedelta(days=later[0] - current) + # Move to the first allowed day of the week `interval` weeks on. + days_to_monday = 7 - current + 1 + week_start = after + timedelta(days=days_to_monday + 7 * (interval - 1)) + return week_start + timedelta(days=allowed[0] - 1) + + +def _step(rule: dict[str, Any], when: datetime) -> datetime: + """One advance of the rule from ``when``.""" + freq = str(rule["freq"]) + interval = int(rule.get("interval") or 1) + + if freq == "daily": + return when + timedelta(days=interval) + if freq == "weekly": + return _next_weekday(when, list(rule.get("weekdays") or []), interval) + if freq == "monthly": + return _add_months(when, interval, int(rule["day_of_month"])) + # yearly + month = int(rule.get("month_of_year") or when.month) + day = int(rule["day_of_month"]) + year = when.year + interval + return when.replace(year=year, month=month, day=_clamp_day(year, month, day)) + + +def validate_rule(rule: dict[str, Any]) -> dict[str, Any]: + """Refuse a rule that cannot produce a date, with a reason. + + The database refuses these too (157's CHECKs). Doing it here as well is not + redundancy for its own sake: an IntegrityError surfaces as a 500 that says + nothing about *which* field was missing, and a rule that silently stops a + series is the failure people notice weeks later. + """ + freq = str(rule.get("freq") or "") + if freq not in FREQS: + raise HTTPException( + status_code=422, + detail=f"Unknown frequency '{freq}'. One of: {list(FREQS)}.", + ) + # `or 1` would be wrong here: an explicit `0` is falsy, so it would become + # "every 1" and pass — a typo that looks exactly like a save, and one the + # database's own CHECK would have refused as a 500 rather than a 422. + # Absent means "every 1"; zero means the sender made a mistake. + raw_interval = rule.get("interval") + interval = 1 if raw_interval is None else int(raw_interval) + if not 1 <= interval <= MAX_INTERVAL: + raise HTTPException( + status_code=422, + detail=f"Repeat every 1 to {MAX_INTERVAL}, not {interval}.", + ) + anchor = str(rule.get("anchor") or "due") + if anchor not in ANCHORS: + raise HTTPException( + status_code=422, + detail=f"Unknown anchor '{anchor}'. One of: {list(ANCHORS)} — " + f"'due' keeps the schedule, 'completed' measures from when " + f"the last one was actually finished.", + ) + weekdays = [int(d) for d in (rule.get("weekdays") or [])] + if any(d < 1 or d > 7 for d in weekdays): + raise HTTPException( + status_code=422, detail="Weekdays are 1 (Monday) to 7 (Sunday).", + ) + if freq == "weekly" and not weekdays: + raise HTTPException( + status_code=422, + detail="A weekly repeat needs at least one weekday, or it has no " + "way to choose a day.", + ) + if freq in ("monthly", "yearly") and not rule.get("day_of_month"): + raise HTTPException( + status_code=422, + detail=f"A {freq} repeat needs a day of the month.", + ) + return { + "freq": freq, + "interval": interval, + "anchor": anchor, + "weekdays": sorted(set(weekdays)), + "day_of_month": rule.get("day_of_month"), + "month_of_year": rule.get("month_of_year"), + "until_at": rule.get("until_at"), + "max_occurrences": rule.get("max_occurrences"), + } + + +def series_exhausted(rule: dict[str, Any], candidate: datetime | None) -> bool: + """Whether the series has ended before this candidate. + + Both limits are honoured and whichever ends it first wins: a rule with + `max_occurrences: 6` and an `until_at` next year stops at six. + """ + if candidate is None: + return True + cap = rule.get("max_occurrences") + if cap is not None and int(rule.get("occurrences_made") or 0) >= int(cap): + return True + until = rule.get("until_at") + return bool(until and candidate > _aware(until)) + + +def _aware(value: Any) -> datetime: + """A stored timestamp as an aware `datetime`. UTC when it says nothing.""" + when = value if isinstance(value, datetime) else datetime.fromisoformat(str(value)) + return when if when.tzinfo else when.replace(tzinfo=UTC) + + +def next_occurrence( + rule: dict[str, Any], + *, + due_at: Any | None, + completed_at: Any | None, + now: datetime, +) -> datetime | None: + """When the successor is due, or ``None`` if the series has ended. + + **Anchor decides what it is measured from**, and the two answers mean + different things (migration 157 spells them out): `due` keeps the schedule, + so finishing late does not drag the series later; `completed` measures the + interval from when the work was actually done. + + **An anchor of `due` CATCHES UP.** A monthly task closed six weeks late + would otherwise produce a successor already in the past — visibly overdue + the moment it appears, which teaches people the date is meaningless. The + rule advances until it lands in the future, and the occurrences that were + missed are *skipped rather than backfilled*: nobody wants four copies of a + stand-up they did not attend. + + An anchor of `completed` never needs catching up — its base is already + "now-ish" — so it takes exactly one step and stays honest about the + interval somebody asked for. + """ + anchor = str(rule.get("anchor") or "due") + base = ( + _aware(completed_at) if anchor == "completed" and completed_at + else _aware(due_at) if due_at + else _aware(completed_at) if completed_at + else now + ) + + candidate = _step(rule, base) + if anchor == "due": + steps = 0 + while candidate <= now and steps < MAX_CATCHUP: + candidate = _step(rule, candidate) + steps += 1 + if candidate <= now: + # Further behind than the cap allows: the series is dead, not late. + return None + + return None if series_exhausted(rule, candidate) else candidate + + +# ── The write path ────────────────────────────────────────────────────────── + +class RecurrenceIn(BaseModel): + freq: str | None = None + interval: int | None = None + anchor: str | None = None + weekdays: list[int] | None = None + day_of_month: int | None = None + month_of_year: int | None = None + until_at: str | None = None + max_occurrences: int | None = None + + +def rule_of(row: Any) -> dict[str, Any]: + """A `pm_recurrences` row as the plain dict the pure functions take.""" + return { + "id": str(row.id), + "project_id": str(row.project_id), + "freq": row.freq, + "interval": row.interval, + "anchor": row.anchor, + "weekdays": list(row.weekdays or []), + "day_of_month": row.day_of_month, + "month_of_year": row.month_of_year, + "until_at": row.until_at.isoformat() if row.until_at else None, + "max_occurrences": row.max_occurrences, + "occurrences_made": row.occurrences_made, + } + + +async def spawn_successor(db: Any, task: Any, *, actor_id: str) -> str | None: + """Create the next occurrence of a closing task. Returns its id, or None. + + Called from `apply_status_transition` — the one place that knows a task has + crossed into a closing category — so a task closed from the board, from My + work, from an automation or from a bulk edit all recur identically. A second + call site would be a fifth way to finish a task that forgets to. + + **Guarded by `recurrence_spawned_at`, not by inference.** A task can cross + into `done` more than once (close, reopen to add a note, close again) and + every crossing reaches this. Without the stamp, one weekly report becomes + three. + """ + if getattr(task, "recurrence_id", None) is None: + return None + if getattr(task, "recurrence_spawned_at", None) is not None: + return None + + row = (await db.execute( + text("SELECT * FROM pm_recurrences WHERE id = CAST(:rid AS uuid)"), + {"rid": str(task.recurrence_id)}, + )).fetchone() + if row is None: + return None + + rule = rule_of(row) + when = next_occurrence( + rule, + due_at=getattr(task, "due_at", None), + completed_at=datetime.now(UTC), + now=datetime.now(UTC), + ) + if when is None: + # The series has ended. Stamped anyway, so a reopen-and-close does not + # re-ask a question already answered. + await update_row( + db, "pm_tasks", str(task.id), + {"recurrence_spawned_at": datetime.now(UTC)}, touch=False, + ) + return None + + values = { + field: getattr(task, field, None) for field in CARRIED_FIELDS + } + values["due_at"] = when + values["recurrence_id"] = rule["id"] + values["created_by"] = actor_id + # `core`'s own helpers, not copies. `next_task_number` allocates in ONE + # statement so two concurrent creates cannot be handed the same number, and + # `load_default_status` is the same "which lane does a new task start in" + # answer `create_task` gives — a second implementation of either would be a + # second answer. + values["status_id"] = str(( + await load_default_status(db, str(task.root_project_id)) + ).id) + values["task_number"] = await next_task_number(db, str(task.root_project_id)) + + successor = await insert_row(db, "pm_tasks", values) + + # Assignees carry over — a cadence belongs to whoever runs it, and a + # recurring task that arrives unassigned every time is a recurring task + # somebody has to re-assign every time. + await db.execute( + text( + "INSERT INTO pm_task_assignees (task_id, assignee, assigned_by) " + "SELECT CAST(:new AS uuid), assignee, :by FROM pm_task_assignees " + " WHERE task_id = CAST(:old AS uuid) " + "ON CONFLICT (task_id, assignee) DO NOTHING" + ), + {"new": str(successor.id), "old": str(task.id), "by": actor_id}, + ) + + await db.execute( + text( + "UPDATE pm_recurrences SET occurrences_made = occurrences_made + 1, " + " updated_at = now() WHERE id = CAST(:rid AS uuid)" + ), + {"rid": rule["id"]}, + ) + await update_row( + db, "pm_tasks", str(task.id), + {"recurrence_spawned_at": datetime.now(UTC)}, touch=False, + ) + await record_activity( + db, activity_type="system", created_by=actor_id, task_id=str(task.id), + body=f"Recurred: next one due {when.date().isoformat()}", + meta={"recurrence_id": rule["id"], "successor_id": str(successor.id)}, + ) + return str(successor.id) + + +# ── Routes ────────────────────────────────────────────────────────────────── + +@router.get("/tasks/{task_id}/recurrence") +async def get_recurrence( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + if task.recurrence_id is None: + return {"rule": None} + row = (await db.execute( + text("SELECT * FROM pm_recurrences WHERE id = CAST(:rid AS uuid)"), + {"rid": str(task.recurrence_id)}, + )).fetchone() + return {"rule": rule_of(row) if row else None} + finally: + await db.close() + + +@router.put("/tasks/{task_id}/recurrence") +async def set_recurrence( + task_id: str, payload: RecurrenceIn, + user: UserContext = Depends(get_current_user), +) -> dict: + """Set or replace this task's repeat rule. + + A PUT rather than a POST/PATCH pair: a task has at most one rule, and + "change the cadence" is the same act as "give it one". + """ + rule = validate_rule(clean_payload(payload)) + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + root = str(task.root_project_id) + + if task.recurrence_id is not None: + # Edited in place, so the whole series keeps one rule and + # `occurrences_made` is not reset by a change of cadence — somebody + # fixing "every 2 weeks" to "every week" has not started over. + row = await update_row(db, "pm_recurrences", str(task.recurrence_id), rule) + else: + row = await insert_row(db, "pm_recurrences", { + **rule, "project_id": root, "created_by": actor(user), + }) + await update_row( + db, "pm_tasks", task_id, {"recurrence_id": str(row.id)}, + ) + await db.commit() + return {"rule": rule_of(row)} + finally: + await db.close() + + +@router.delete("/tasks/{task_id}/recurrence") +async def clear_recurrence( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Stop the series. The task itself, and every occurrence already made, stay. + + Deleting the rule does not delete the tasks it produced: they are real work, + some of it finished, and a "stop repeating this" button that swept away + three months of completed reports would be the last time anybody pressed it. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + if task.recurrence_id is None: + # Already in the target state (the house idempotency rule): not an + # error, and no write. + return {"cleared": False} + rule_id = str(task.recurrence_id) + detached = int((await db.execute( + text( + "UPDATE pm_tasks SET recurrence_id = NULL " + " WHERE recurrence_id = CAST(:rid AS uuid)" + ), + {"rid": rule_id}, + )).rowcount or 0) + await db.execute( + text("DELETE FROM pm_recurrences WHERE id = CAST(:rid AS uuid)"), + {"rid": rule_id}, + ) + await db.commit() + return {"cleared": True, "cascaded": {"tasks_detached": detached}} + finally: + await db.close() + + +__all__ = [ + "ANCHORS", + "CARRIED_FIELDS", + "FREQS", + "MAX_CATCHUP", + "MAX_INTERVAL", + "next_occurrence", + "rule_of", + "series_exhausted", + "spawn_successor", + "validate_rule", +] diff --git a/infra/postgres/157_projects_recurrence.sql b/infra/postgres/157_projects_recurrence.sql new file mode 100644 index 00000000..e9df4ca1 --- /dev/null +++ b/infra/postgres/157_projects_recurrence.sql @@ -0,0 +1,114 @@ +-- 157_projects_recurrence.sql — WS-27o +-- +-- Spec: ai-company-brain/specs/project_management_app.md §11.2 item 7, §11.13. +-- +-- "Every operations cadence is recurring. Without it those live in someone's +-- head or in ClickUp." +-- +-- NO SCHEDULER, AND THAT IS FORCED RATHER THAN CHOSEN. §5's non-goals: +-- "A second automation engine. ADR-028/D6: /workflows is the only engine; WS-27 +-- contributes events and node types to it." A recurrence worker inside this app +-- would be exactly that second engine. So the next instance is created **when a +-- task closes** — `apply_status_transition` already owns that moment — and the +-- whole feature needs no cron, no worker and no new transport. +-- +-- WHAT THAT COSTS, STATED: a series only advances when somebody finishes the +-- current one. A monthly report nobody closes does not pile up twelve copies, +-- which is right; a daily standup nobody ticks does not appear tomorrow, which +-- is the honest limitation. Materialising ahead of time is already reachable +-- through the engine that owns scheduling — a cron trigger plus the `pm_task` +-- node WS-27f added — so nothing here has to be undone to get it. + +BEGIN; + +CREATE TABLE IF NOT EXISTS pm_recurrences ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- The ROOT project, as every other piece of task configuration is. It is + -- also the scope the successor is created in, so a series cannot quietly + -- walk into a project nobody granted. + project_id UUID NOT NULL REFERENCES pm_projects (id) ON DELETE CASCADE, + + freq TEXT NOT NULL + CHECK (freq IN ('daily', 'weekly', 'monthly', 'yearly')), + + -- "every N". Bounded: an interval of 100000 days is not a cadence, it is a + -- way to put a date far enough out that nobody notices the series is dead. + interval INTEGER NOT NULL DEFAULT 1 + CHECK (interval BETWEEN 1 AND 365), + + -- ISO weekdays for `weekly`: 1 = Monday … 7 = Sunday. "Every weekday" is + -- `weekly` with {1,2,3,4,5} rather than a fifth `freq`, because it IS that + -- and a separate value would need its own interval semantics. + weekdays SMALLINT[] NOT NULL DEFAULT '{}'::smallint[] + CHECK (weekdays <@ ARRAY[1,2,3,4,5,6,7]::smallint[]), + + -- For `monthly` and `yearly`. 31 is legal and is the interesting case: it + -- is CLAMPED to the length of the target month at computation time, never + -- stored differently, so "the 31st" stays "the 31st" in the months that + -- have one instead of silently becoming "the 28th" forever. + day_of_month SMALLINT CHECK (day_of_month BETWEEN 1 AND 31), + month_of_year SMALLINT CHECK (month_of_year BETWEEN 1 AND 12), + + -- WHAT THE NEXT DUE DATE IS MEASURED FROM, and the two answers mean + -- genuinely different things: + -- 'due' — the schedule. "Stock count on the 1st" stays on the 1st + -- however late the last one was closed. The series does not + -- drift. + -- 'completed' — the interval since it was actually done. "Water the + -- plants every 3 days" restarts when you water them. + -- Neither is a sensible global default, so it is per rule. + anchor TEXT NOT NULL DEFAULT 'due' + CHECK (anchor IN ('due', 'completed')), + + -- Ending a series. Both optional, both honoured; whichever ends it first + -- wins. Without either, a cadence runs until somebody turns it off — which + -- is what an operations cadence actually is. + until_at TIMESTAMPTZ, + max_occurrences INTEGER CHECK (max_occurrences > 0), + occurrences_made INTEGER NOT NULL DEFAULT 0 CHECK (occurrences_made >= 0), + + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- A weekly rule with no weekdays has no way to pick a day, and a monthly + -- one with no day-of-month has no way to pick a date. Refused here as well + -- as in Python, because a rule that cannot produce a date is a series that + -- silently stops. + -- `coalesce` is load-bearing, not defensive. `array_length('{}', 1)` returns + -- NULL rather than 0, `NULL >= 1` is NULL, and a CHECK only FAILS on false — + -- so without it this constraint evaluates to NULL for exactly the row it + -- exists to reject, and a weekly rule with no weekdays inserts happily. + -- Found by running the migration rather than by reading it. + CONSTRAINT pm_recurrences_weekly_needs_days + CHECK (freq <> 'weekly' OR coalesce(array_length(weekdays, 1), 0) >= 1), + CONSTRAINT pm_recurrences_monthly_needs_a_day + CHECK (freq NOT IN ('monthly', 'yearly') OR day_of_month IS NOT NULL) +); + +CREATE INDEX IF NOT EXISTS idx_pm_recurrences_project + ON pm_recurrences (project_id); + +ALTER TABLE pm_tasks + ADD COLUMN IF NOT EXISTS recurrence_id UUID + REFERENCES pm_recurrences (id) ON DELETE SET NULL; + +-- Stamped when this task has produced its successor. +-- +-- THE IDEMPOTENCY GUARD, and it is the whole reason this column exists rather +-- than the spawn being inferred. A task can cross into `done` more than once — +-- somebody closes it, reopens it to add a note, closes it again — and each +-- crossing hits the same seam. Without this, one weekly report becomes three. +-- It is never cleared: reopening undoes `completed_at`, but it does not un-emit +-- a successor that already exists and may already have been worked on. +ALTER TABLE pm_tasks + ADD COLUMN IF NOT EXISTS recurrence_spawned_at TIMESTAMPTZ; + +-- Finding a series. Partial, because the overwhelming majority of tasks carry +-- no recurrence at all and an index over all of them would be mostly nulls. +CREATE INDEX IF NOT EXISTS idx_pm_tasks_recurrence + ON pm_tasks (recurrence_id) + WHERE recurrence_id IS NOT NULL; + +COMMIT; diff --git a/tests/unit/test_projects_recurrence.py b/tests/unit/test_projects_recurrence.py new file mode 100644 index 00000000..1d00aa2e --- /dev/null +++ b/tests/unit/test_projects_recurrence.py @@ -0,0 +1,437 @@ +"""WS-27o — recurring tasks. + +Spec: `ai-company-brain/specs/project_management_app.md` §11.2 item 7, §11.13. + +Recurrence looks trivial and is not. Each of these is a different way to be +quietly wrong for a year: + +* **January 31st, monthly.** Clamping to February and *storing* the clamped day + permanently demotes the rule to the 28th. It has to clamp at computation time. +* **February 29th, yearly.** Same shape, once every four years. +* **A weekly rule crossing Sunday.** "Mon and Thu, every 2 weeks" must land on + both days of the right weeks, not alternate between them. +* **A task closed six weeks late.** An anchor of `due` that does not catch up + produces a successor already overdue the moment it appears, which teaches + people the date means nothing. +* **A task closed twice.** Reopening and re-closing must not spawn a second + successor. + +Pure functions, tested directly. No Postgres, no fake. +""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from fastapi import HTTPException +from gateway.routes.projects.recurrence import ( + ANCHORS, + CARRIED_FIELDS, + FREQS, + MAX_CATCHUP, + next_occurrence, + series_exhausted, + validate_rule, +) + +REPO = Path(__file__).resolve().parents[2] +MIGRATION = REPO / "infra/postgres/157_projects_recurrence.sql" + + +def sql_without_comments() -> str: + text = MIGRATION.read_text(encoding="utf-8") + return "\n".join(re.sub(r"--.*$", "", line) for line in text.splitlines()) + + +def at(spec: str) -> datetime: + return datetime.fromisoformat(spec).replace(tzinfo=UTC) + + +def rule(**over) -> dict: + return {"freq": "daily", "interval": 1, "anchor": "due", "weekdays": [], **over} + + +def nxt(r: dict, *, due=None, completed=None, now="2026-01-01T00:00:00") -> datetime | None: + return next_occurrence( + r, due_at=due, completed_at=completed, now=at(now), + ) + + +# ── The vocabulary matches the schema ─────────────────────────────────────── + +def test_the_frequencies_are_the_ones_the_database_allows(): + match = re.search(r"freq\s+TEXT\s+NOT\s+NULL\s*CHECK\s*\(\s*freq\s+IN\s*\((.*?)\)\)", + sql_without_comments(), re.S | re.I) + assert match, "157 no longer constrains pm_recurrences.freq" + assert set(re.findall(r"'(\w+)'", match.group(1))) == set(FREQS) + + +def test_the_anchors_are_the_ones_the_database_allows(): + match = re.search(r"anchor\s+TEXT[^,]*?CHECK\s*\(\s*anchor\s+IN\s*\((.*?)\)\)", + sql_without_comments(), re.S | re.I) + assert match, "157 no longer constrains pm_recurrences.anchor" + assert set(re.findall(r"'(\w+)'", match.group(1))) == set(ANCHORS) + + +def test_the_database_refuses_a_rule_that_cannot_pick_a_date(): + """A weekly rule with no weekdays, or a monthly one with no day, is a series + that silently stops. Refused on both sides.""" + sql = sql_without_comments() + assert "pm_recurrences_weekly_needs_days" in sql + assert "pm_recurrences_monthly_needs_a_day" in sql + + +def test_the_weekly_check_survives_an_EMPTY_array_not_just_a_missing_one(): + """A bug the live run caught and reading could not. + + `array_length('{}', 1)` returns **NULL**, not 0. `NULL >= 1` is NULL, and a + CHECK constraint only fails on FALSE — so the un-coalesced form evaluated to + NULL for exactly the row it existed to reject, and a weekly rule with no + weekdays inserted happily past a constraint that looked correct. + + Asserted against the SQL because the claim is about the expression, and the + hermetic suite has no database to try it on. + """ + match = re.search( + r"CHECK \(freq <> 'weekly' OR ([^)]+\)[^)]*)\)", sql_without_comments(), + ) + assert match, "157 no longer guards a weekly rule's weekdays" + assert "coalesce(" in match.group(1), ( + "array_length of an empty array is NULL, so this CHECK passes the very " + "row it exists to reject unless the NULL is coalesced" + ) + + +def test_the_idempotency_stamp_exists_in_the_schema(): + """The whole reason one weekly report does not become three.""" + assert re.search( + r"ADD COLUMN IF NOT EXISTS recurrence_spawned_at\s+TIMESTAMPTZ", + sql_without_comments(), re.I, + ) + + +def test_no_scheduler_table_was_added(): + """§5: `/workflows` is the only engine. A queue or a due-runs table here + would be the second one this spec forbids.""" + sql = sql_without_comments().lower() + for forbidden in ("pm_recurrence_queue", "pm_scheduled", "next_run_at"): + assert forbidden not in sql, f"157 grew a scheduler ({forbidden})" + + +# ── Validation ────────────────────────────────────────────────────────────── + +def test_an_unknown_frequency_is_refused_and_lists_the_real_ones(): + with pytest.raises(HTTPException) as exc: + validate_rule({"freq": "fortnightly"}) + assert exc.value.status_code == 422 + for known in FREQS: + assert known in str(exc.value.detail) + + +def test_a_weekly_rule_without_weekdays_is_refused(): + with pytest.raises(HTTPException) as exc: + validate_rule({"freq": "weekly"}) + assert "weekday" in str(exc.value.detail) + + +def test_a_monthly_rule_without_a_day_is_refused(): + with pytest.raises(HTTPException) as exc: + validate_rule({"freq": "monthly"}) + assert "day of the month" in str(exc.value.detail) + + +def test_an_unknown_anchor_is_refused_and_explains_the_difference(): + """The two anchors mean genuinely different things, so the error says so + rather than only listing them.""" + with pytest.raises(HTTPException) as exc: + validate_rule({"freq": "daily", "anchor": "whenever"}) + detail = str(exc.value.detail) + assert "schedule" in detail and "finished" in detail + + +def test_an_out_of_range_interval_is_refused(): + for bad in (0, -1, 10_000): + with pytest.raises(HTTPException): + validate_rule({"freq": "daily", "interval": bad}) + + +def test_an_impossible_weekday_is_refused(): + with pytest.raises(HTTPException): + validate_rule({"freq": "weekly", "weekdays": [0]}) + with pytest.raises(HTTPException): + validate_rule({"freq": "weekly", "weekdays": [8]}) + + +def test_weekdays_are_deduplicated_and_ordered(): + assert validate_rule({"freq": "weekly", "weekdays": [4, 1, 4]})["weekdays"] == [1, 4] + + +def test_the_default_anchor_is_the_schedule(): + """`due` keeps a cadence on its dates. Defaulting to `completed` would make + every series drift the first time somebody was a day late.""" + assert validate_rule({"freq": "daily"})["anchor"] == "due" + + +# ── Daily ─────────────────────────────────────────────────────────────────── + +def test_daily_advances_by_a_day(): + assert nxt(rule(), due="2026-01-05T09:00:00", now="2026-01-05T10:00:00") == at( + "2026-01-06T09:00:00" + ) + + +def test_every_n_days_advances_by_n(): + assert nxt( + rule(interval=3), due="2026-01-05T09:00:00", now="2026-01-05T10:00:00" + ) == at("2026-01-08T09:00:00") + + +def test_the_time_of_day_is_kept(): + """A stand-up at 09:00 must not become a stand-up at midnight.""" + assert nxt( + rule(), due="2026-01-05T09:30:00", now="2026-01-05T10:00:00" + ).time() == at("2026-01-05T09:30:00").time() + + +# ── Monthly — the January 31st case ───────────────────────────────────────── + +def test_the_31st_becomes_the_28th_in_february_but_stays_the_31st_after(): + """The single most important case here. Clamping is what February needs; + clamping *permanently* is the bug — a rule stored as the 28th can never + return to the 31st, so a monthly report quietly moves three days earlier + forever after its first February.""" + r = rule(freq="monthly", day_of_month=31) + feb = nxt(r, due="2026-01-31T09:00:00", now="2026-01-31T10:00:00") + assert feb == at("2026-02-28T09:00:00") + + # And the NEXT step, computed from the rule rather than from the clamp, + # goes back to the 31st. + march = nxt(r, due=feb, now="2026-02-28T10:00:00") + assert march == at("2026-03-31T09:00:00") + + +def test_the_31st_lands_on_the_30th_in_a_thirty_day_month(): + r = rule(freq="monthly", day_of_month=31) + assert nxt(r, due="2026-03-31T09:00:00", now="2026-03-31T10:00:00") == at( + "2026-04-30T09:00:00" + ) + + +def test_a_leap_february_gets_the_29th(): + r = rule(freq="monthly", day_of_month=31) + assert nxt(r, due="2028-01-31T09:00:00", now="2028-01-31T10:00:00") == at( + "2028-02-29T09:00:00" + ) + + +def test_monthly_crosses_the_year_boundary(): + r = rule(freq="monthly", day_of_month=15) + assert nxt(r, due="2026-12-15T09:00:00", now="2026-12-15T10:00:00") == at( + "2027-01-15T09:00:00" + ) + + +def test_every_other_month(): + r = rule(freq="monthly", interval=2, day_of_month=1) + assert nxt(r, due="2026-01-01T09:00:00", now="2026-01-01T10:00:00") == at( + "2026-03-01T09:00:00" + ) + + +# ── Yearly — February 29th ────────────────────────────────────────────────── + +def test_a_february_29th_rule_clamps_in_a_common_year_and_returns_in_a_leap_one(): + """Once every four years, and permanently wrong if the clamp is stored.""" + r = rule(freq="yearly", day_of_month=29, month_of_year=2) + common = nxt(r, due="2028-02-29T09:00:00", now="2028-03-01T00:00:00") + assert common == at("2029-02-28T09:00:00") + + r_leap = rule(freq="yearly", interval=4, day_of_month=29, month_of_year=2) + assert nxt(r_leap, due="2028-02-29T09:00:00", now="2028-03-01T00:00:00") == at( + "2032-02-29T09:00:00" + ) + + +def test_a_yearly_rule_moves_the_date_to_ITS_month_not_the_one_it_came_from(): + """The fixtures above all have a due date already in the rule's month, so + reading `when.month` instead of the rule would give the same answer. Here + the two differ: a rule that says April, from a task due in November.""" + r = rule(freq="yearly", day_of_month=6, month_of_year=4) + assert nxt(r, due="2026-11-20T09:00:00", now="2026-11-20T10:00:00") == at( + "2027-04-06T09:00:00" + ) + + +# ── Weekly ────────────────────────────────────────────────────────────────── + +def test_weekly_takes_the_next_allowed_day_in_the_same_week(): + # 2026-01-05 is a Monday. Mon(1) and Thu(4). + r = rule(freq="weekly", weekdays=[1, 4]) + assert nxt(r, due="2026-01-05T09:00:00", now="2026-01-05T10:00:00") == at( + "2026-01-08T09:00:00" + ) + + +def test_weekly_wraps_to_the_next_week_when_the_days_run_out(): + r = rule(freq="weekly", weekdays=[1, 4]) + # From Thursday the next allowed day is the following Monday. + assert nxt(r, due="2026-01-08T09:00:00", now="2026-01-08T10:00:00") == at( + "2026-01-12T09:00:00" + ) + + +def test_every_other_week_lands_on_BOTH_days_of_the_right_weeks(): + """The case a naive "+14 days" gets wrong: it would alternate between Monday + and Thursday instead of giving both days of every second week.""" + r = rule(freq="weekly", interval=2, weekdays=[1, 4]) + # Mon 5th → Thu 8th, same week: the interval does not apply within a week. + assert nxt(r, due="2026-01-05T09:00:00", now="2026-01-05T10:00:00") == at( + "2026-01-08T09:00:00" + ) + # Thu 8th → skips a week → Mon 19th, not Mon 12th. + assert nxt(r, due="2026-01-08T09:00:00", now="2026-01-08T10:00:00") == at( + "2026-01-19T09:00:00" + ) + + +def test_a_sunday_only_rule_advances_by_a_week(): + r = rule(freq="weekly", weekdays=[7]) + # 2026-01-04 is a Sunday. + assert nxt(r, due="2026-01-04T09:00:00", now="2026-01-04T10:00:00") == at( + "2026-01-11T09:00:00" + ) + + +# ── Anchors ───────────────────────────────────────────────────────────────── + +def test_an_anchor_of_due_keeps_the_schedule_when_the_task_was_closed_late(): + """"Stock count on the 1st" stays on the 1st. Measuring from completion + would drag the whole series later every time somebody was busy.""" + r = rule(freq="monthly", day_of_month=1, anchor="due") + assert nxt( + r, due="2026-02-01T09:00:00", completed="2026-02-09T17:00:00", + now="2026-02-09T17:00:00", + ) == at("2026-03-01T09:00:00") + + +def test_an_anchor_of_completed_measures_from_when_it_was_actually_done(): + """"Water the plants every 3 days" restarts when you water them.""" + r = rule(freq="daily", interval=3, anchor="completed") + assert nxt( + r, due="2026-02-01T09:00:00", completed="2026-02-09T17:00:00", + now="2026-02-09T17:00:00", + ) == at("2026-02-12T17:00:00") + + +def test_a_due_anchored_rule_catches_up_rather_than_landing_in_the_past(): + """A monthly task closed six weeks late would otherwise produce a successor + already overdue the moment it appears — which teaches people the date is + meaningless.""" + r = rule(freq="monthly", day_of_month=1, anchor="due") + got = nxt(r, due="2026-01-01T09:00:00", now="2026-04-15T00:00:00") + assert got == at("2026-05-01T09:00:00") + + +def test_catching_up_SKIPS_the_missed_ones_rather_than_backfilling(): + """Nobody wants four copies of a stand-up they did not attend. The proof is + that one call returns one date, and it is the next FUTURE one.""" + r = rule(freq="daily", anchor="due") + got = nxt(r, due="2026-01-01T09:00:00", now="2026-01-10T12:00:00") + assert got == at("2026-01-11T09:00:00") + + +def test_a_series_further_behind_than_the_catch_up_cap_is_dead_not_late(): + r = rule(freq="daily", anchor="due") + assert nxt(r, due="1990-01-01T09:00:00", now="2026-01-01T00:00:00") is None + + +def test_the_catch_up_cap_is_generous_enough_for_a_real_lapse(): + """Bounded because an unbounded loop over data somebody can create is a + denial of service — but a daily task must survive years of neglect.""" + assert MAX_CATCHUP >= 365 * 3 + + +def test_a_completed_anchor_takes_exactly_ONE_step_even_from_an_old_completion(): + """"Every 3 days after you did it" means three days after you did it. + + The fixture deliberately puts the completion months behind `now`, because + with a completion of "just now" a catch-up loop and no catch-up loop give + the same answer, and the assertion would prove nothing. Here they differ: + catching up would say June, and the honest answer is January.""" + r = rule(freq="daily", interval=3, anchor="completed") + assert nxt( + r, due="2020-01-01T09:00:00", completed="2026-01-01T08:00:00", + now="2026-06-01T00:00:00", + ) == at("2026-01-04T08:00:00") + + +def test_a_task_with_no_due_date_falls_back_to_when_it_was_completed(): + r = rule(freq="daily", anchor="due") + assert nxt(r, completed="2026-01-05T09:00:00", now="2026-01-05T10:00:00") == at( + "2026-01-06T09:00:00" + ) + + +# ── Ending a series ───────────────────────────────────────────────────────── + +def test_a_series_stops_at_its_until_date(): + r = rule(freq="daily", until_at="2026-01-05T00:00:00") + assert nxt(r, due="2026-01-04T09:00:00", now="2026-01-04T10:00:00") is None + + +def test_a_series_stops_after_its_occurrence_cap(): + r = rule(freq="daily", max_occurrences=3, occurrences_made=3) + assert nxt(r, due="2026-01-04T09:00:00", now="2026-01-04T10:00:00") is None + + +def test_a_series_below_its_cap_keeps_going(): + r = rule(freq="daily", max_occurrences=3, occurrences_made=2) + assert nxt(r, due="2026-01-04T09:00:00", now="2026-01-04T10:00:00") is not None + + +def test_whichever_limit_ends_it_first_wins(): + """A rule with `max_occurrences: 6` and an `until_at` next year stops at + six, and one with two left but a date yesterday stops on the date.""" + soon = rule(freq="daily", max_occurrences=6, occurrences_made=6, + until_at="2099-01-01T00:00:00") + assert series_exhausted(soon, at("2026-06-01T00:00:00")) is True + + dated = rule(freq="daily", max_occurrences=6, occurrences_made=1, + until_at="2026-01-01T00:00:00") + assert series_exhausted(dated, at("2026-06-01T00:00:00")) is True + + +def test_no_limits_means_the_series_runs_until_somebody_stops_it(): + """Which is what an operations cadence actually is.""" + assert series_exhausted(rule(), at("2099-01-01T00:00:00")) is False + + +# ── What carries to the successor ─────────────────────────────────────────── + +def test_the_successor_does_NOT_inherit_the_finished_state(): + """"This month's report" has not been started, and a successor that arrives + already `done` is a series that only ever runs once.""" + for absent in ("status_id", "completed_at", "task_number"): + assert absent not in CARRIED_FIELDS + + +def test_the_successor_does_not_inherit_last_month_s_conversation(): + """Comments and attachments belong to the occurrence they were made on. + Copying them is how a recurring task becomes unreadable by March.""" + for absent in ("recurrence_spawned_at", "clickup_id"): + assert absent not in CARRIED_FIELDS + + +def test_the_successor_DOES_inherit_what_makes_it_the_same_work(): + for carried in ("title", "description", "importance", "tags", "custom_fields"): + assert carried in CARRIED_FIELDS + + +def test_it_stays_in_the_same_project(): + """A series that wandered into another project would escape the grant that + scoped it.""" + assert "project_id" in CARRIED_FIELDS + assert "root_project_id" in CARRIED_FIELDS diff --git a/workbench/control_plane/src/app/projects/components/RepeatEditor.tsx b/workbench/control_plane/src/app/projects/components/RepeatEditor.tsx new file mode 100644 index 00000000..27910807 --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/RepeatEditor.tsx @@ -0,0 +1,281 @@ +"use client"; + +/** + * Projects · the repeat rule, in the task panel (WS-27o). + * + * **The sentence is the feature.** A form of five controls is a shape; a line + * reading *"Every 2 weeks on Mon, Thu, keeping to the schedule"* is something + * somebody can check before they commit to it — and it is shown live, not on + * save, because the mistake this prevents (picking the wrong anchor) is + * invisible until a cadence has drifted for three months. + * + * Saving is explicit. Every other control in this panel writes as you touch it, + * but a repeat rule is a decision with a shape, and autosaving a half-built one + * would push a weekly rule with no weekday at the server on every keystroke. + */ + +import Badge from "@/components/ui/Badge"; +import Button from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { useEffect, useState } from "react"; + +import { projectsApi } from "../lib/api"; +import { + ANCHORS, + FREQS, + type Freq, + type Rule, + WEEKDAY_LABELS, + describeRule, + emptyRule, + ruleProblem, + toPayload, + toggleWeekday, +} from "../lib/recurrence"; + +const SELECT = + "cc-control rounded-lg border border-border bg-background px-2 py-1.5 " + + "text-xs text-foreground outline-none focus:border-primary/50"; + +const FREQ_LABELS: Record = { + daily: "Daily", + weekly: "Weekly", + monthly: "Monthly", + yearly: "Yearly", +}; + +const ANCHOR_LABELS: Record = { + due: "Keep to the schedule", + completed: "Measure from when it is finished", +}; + +interface Props { + taskId: string; +} + +export function RepeatEditor({ taskId }: Props) { + const [saved, setSaved] = useState(null); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + projectsApi + .recurrence(taskId) + .then((res) => { + if (!live) return; + setSaved(res.rule); + setDraft(null); + }) + // A panel that works without its repeat row beats one that refuses to + // open because the row did not load. + .catch(() => { + if (live) setSaved(null); + }); + return () => { + live = false; + }; + }, [taskId]); + + const editing = draft !== null; + const problem = draft ? ruleProblem(draft) : null; + + async function save() { + if (!draft || problem) return; + setBusy(true); + setError(null); + try { + const res = await projectsApi.setRecurrence(taskId, toPayload(draft)); + setSaved(res.rule); + setDraft(null); + } catch (err) { + setError(String((err as Error).message)); + } finally { + setBusy(false); + } + } + + async function stop() { + setBusy(true); + setError(null); + try { + await projectsApi.clearRecurrence(taskId); + setSaved(null); + setDraft(null); + } catch (err) { + setError(String((err as Error).message)); + } finally { + setBusy(false); + } + } + + const set = (patch: Partial) => + setDraft((current) => ({ ...(current ?? emptyRule()), ...patch })); + + return ( +
+ Repeats + + {error ? ( +

+ {error} +

+ ) : null} + + {!editing ? ( +
+ {saved ? ( + <> + + on + + + {describeRule(saved)} + + + + + ) : ( + <> + + Does not repeat. + + + + )} +
+ ) : ( +
+
+ Every + set({ interval: Number(e.target.value) })} + /> + +
+ + {draft.freq === "weekly" ? ( +
+ {WEEKDAY_LABELS.map(([n, label]) => { + const on = draft.weekdays.includes(n); + return ( + + ); + })} +
+ ) : null} + + {draft.freq === "monthly" || draft.freq === "yearly" ? ( +
+ {draft.freq === "yearly" ? ( + + ) : null} + on day + set({ day_of_month: Number(e.target.value) })} + /> + {/* Said out loud, because "the 31st" in February is the one thing + about a monthly rule that surprises people. */} + {(draft.day_of_month ?? 0) > 28 ? ( + + Shorter months use their last day. + + ) : null} +
+ ) : null} + + + + {/* Live, not on save: picking the wrong anchor is invisible until a + cadence has drifted for three months. */} +

+ {problem ?? describeRule(draft)} +

+ +
+ + +
+
+ )} +
+ ); +} diff --git a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx index 7be9c6ef..3d137248 100644 --- a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx @@ -22,6 +22,7 @@ import { } from "../lib/api"; import { CustomFieldValues } from "./CustomFieldValues"; import { TagPicker } from "./TagPicker"; +import { RepeatEditor } from "./RepeatEditor"; import { changeLabel } from "../lib/customFields"; import { assigneeLabel, @@ -388,6 +389,7 @@ export function TaskPanel({ })(); }} /> +
Files diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts index 8077cdcf..9632dbb7 100644 --- a/workbench/control_plane/src/app/projects/lib/api.ts +++ b/workbench/control_plane/src/app/projects/lib/api.ts @@ -1,3 +1,5 @@ +import type { Rule as RecurrenceRule } from "./recurrence"; + /** * Projects · the browser's client for /api/projects/*. * @@ -201,6 +203,24 @@ export const projectsApi = { body: JSON.stringify({ body }), }), + /** WS-27o — this task's repeat rule, or `{rule: null}`. */ + recurrence: (taskId: string) => + call<{ rule: RecurrenceRule | null }>(`tasks/${taskId}/recurrence`), + + /** Set or replace it. A task has at most one rule, so this is a PUT. */ + setRecurrence: (taskId: string, payload: Record) => + call<{ rule: RecurrenceRule }>(`tasks/${taskId}/recurrence`, { + method: "PUT", + body: JSON.stringify(payload), + }), + + /** Stop the series. Everything it already created stays. */ + clearRecurrence: (taskId: string) => + call<{ cleared: boolean; cascaded?: { tasks_detached: number } }>( + `tasks/${taskId}/recurrence`, + { method: "DELETE" } + ), + /** * WS-27n — one edit applied to many tasks. * diff --git a/workbench/control_plane/src/app/projects/lib/recurrence.test.ts b/workbench/control_plane/src/app/projects/lib/recurrence.test.ts new file mode 100644 index 00000000..da5ab73e --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/recurrence.test.ts @@ -0,0 +1,194 @@ +/** + * Projects · the repeat rule in the browser (WS-27o). + * + * The gateway owns the date arithmetic. This owns saying what a rule MEANS + * before somebody commits to it — and the cases worth pinning are the ones + * where a sentence would read wrong: a plural that should be singular, a count + * that shows the cap instead of what is left, and an anchor whose two values a + * reader cannot guess. + */ + +import { describe, expect, it } from "vitest"; + +import { + ANCHORS, + FREQS, + MAX_INTERVAL, + type Rule, + describeRule, + emptyRule, + ordinal, + ruleProblem, + toPayload, + toggleWeekday, +} from "./recurrence"; + +const rule = (over: Partial = {}): Rule => ({ ...emptyRule(), ...over }); + +describe("ordinal", () => { + it("handles the suffixes", () => { + expect([1, 2, 3, 4, 21, 22, 23, 31].map(ordinal)).toEqual([ + "1st", "2nd", "3rd", "4th", "21st", "22nd", "23rd", "31st", + ]); + }); + + it("gets the teens right, which the naive rule does not", () => { + // 11, 12 and 13 end in 1, 2 and 3 but are "th". + expect([11, 12, 13].map(ordinal)).toEqual(["11th", "12th", "13th"]); + }); +}); + +describe("describeRule", () => { + it("says every day, not every 1 days", () => { + expect(describeRule(rule({ freq: "daily", interval: 1 }))).toContain("Every day"); + }); + + it("pluralises a real interval", () => { + expect(describeRule(rule({ freq: "daily", interval: 3 }))).toContain("Every 3 days"); + }); + + it("names the weekdays in order, whatever order they were picked in", () => { + const said = describeRule( + rule({ freq: "weekly", interval: 2, weekdays: [4, 1] }) + ); + expect(said).toContain("Every 2 weeks on Mon, Thu"); + }); + + it("reads a monthly rule as a date", () => { + expect( + describeRule(rule({ freq: "monthly", day_of_month: 31 })) + ).toContain("Every month on the 31st"); + }); + + it("names the month for a yearly rule", () => { + expect( + describeRule(rule({ freq: "yearly", day_of_month: 29, month_of_year: 2 })) + ).toContain("Every year on February 29th"); + }); + + it("spells the anchor out rather than naming it", () => { + // "due" and "completed" are the two words in this feature a reader cannot + // guess — and choosing wrong makes a cadence drift later every month. + expect(describeRule(rule({ anchor: "due", weekdays: [1] }))).toContain( + "keeping to the schedule" + ); + expect(describeRule(rule({ anchor: "completed", weekdays: [1] }))).toContain( + "measured from when it is finished" + ); + }); + + it("counts what is LEFT, not the cap", () => { + // "6 times" beside a series that has already run five reads as five more + // to come. + const said = describeRule( + rule({ weekdays: [1], max_occurrences: 6, occurrences_made: 5 }) + ); + expect(said).toContain("1 more time"); + expect(said).not.toContain("6 more"); + }); + + it("does not go negative when the cap has been passed", () => { + const said = describeRule( + rule({ weekdays: [1], max_occurrences: 2, occurrences_made: 5 }) + ); + expect(said).toContain("0 more times"); + expect(said).not.toContain("-3"); + }); + + it("ignores an unparseable until date rather than saying Invalid Date", () => { + const said = describeRule(rule({ weekdays: [1], until_at: "soon" })); + expect(said).not.toMatch(/invalid/i); + }); + + it("says nothing about limits when there are none", () => { + const said = describeRule(rule({ weekdays: [1] })); + expect(said).not.toContain("more time"); + expect(said).not.toContain("until"); + }); +}); + +describe("ruleProblem", () => { + it("is null for a rule that can be saved", () => { + expect(ruleProblem(rule({ freq: "weekly", weekdays: [1] }))).toBeNull(); + }); + + it("catches a weekly rule with no day chosen", () => { + expect(ruleProblem(rule({ freq: "weekly", weekdays: [] }))).toMatch(/day of the week/); + }); + + it("catches a monthly rule with no date", () => { + expect(ruleProblem(rule({ freq: "monthly" }))).toMatch(/day of the month/); + }); + + it("catches an interval of zero, which the server also refuses", () => { + // The falsy-zero trap on the server was real; this is its front half. + expect(ruleProblem(rule({ freq: "daily", interval: 0 }))).not.toBeNull(); + }); + + it("catches an interval past the cap", () => { + expect(ruleProblem(rule({ freq: "daily", interval: MAX_INTERVAL + 1 }))).not.toBeNull(); + }); + + it("catches a fractional interval", () => { + expect(ruleProblem(rule({ freq: "daily", interval: 1.5 }))).not.toBeNull(); + }); +}); + +describe("toggleWeekday", () => { + it("adds and removes", () => { + expect(toggleWeekday([], 3)).toEqual([3]); + expect(toggleWeekday([3], 3)).toEqual([]); + }); + + it("keeps the list sorted so the sentence reads in order", () => { + expect(toggleWeekday([4], 1)).toEqual([1, 4]); + }); + + it("does not mutate the list it was given", () => { + const current = [1]; + toggleWeekday(current, 4); + expect(current).toEqual([1]); + }); +}); + +describe("toPayload", () => { + it("clears the fields the chosen frequency does not use", () => { + // A rule edited from monthly to weekly must not keep a stale day_of_month + // that reappears the moment somebody switches back. + const payload = toPayload( + rule({ freq: "weekly", weekdays: [1], day_of_month: 31, month_of_year: 2 }) + ); + expect(payload.day_of_month).toBeNull(); + expect(payload.month_of_year).toBeNull(); + expect(payload.weekdays).toEqual([1]); + }); + + it("clears weekdays when the frequency is not weekly", () => { + expect( + toPayload(rule({ freq: "monthly", day_of_month: 1, weekdays: [1, 4] })).weekdays + ).toEqual([]); + }); + + it("defaults a yearly rule's month rather than sending null", () => { + // The gateway falls back to the due date's month, which would make the + // same rule mean different things on different tasks. + expect(toPayload(rule({ freq: "yearly", day_of_month: 1 })).month_of_year).toBe(1); + }); + + it("sends null rather than an empty string for the end date", () => { + expect(toPayload(rule({ weekdays: [1], until_at: "" })).until_at).toBeNull(); + }); +}); + +describe("the vocabulary", () => { + it("matches the gateway's", () => { + expect(FREQS).toEqual(["daily", "weekly", "monthly", "yearly"]); + expect(ANCHORS).toEqual(["due", "completed"]); + }); + + it("starts a new rule on something that needs one more choice", () => { + // Weekly with no day chosen: the form opens asking a question rather than + // pre-filling an answer nobody made. + expect(ruleProblem(emptyRule())).not.toBeNull(); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/recurrence.ts b/workbench/control_plane/src/app/projects/lib/recurrence.ts new file mode 100644 index 00000000..c34f3e9f --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/recurrence.ts @@ -0,0 +1,172 @@ +/** + * Projects · the repeat rule in the browser (WS-27o). + * + * The gateway owns the date arithmetic — `routes/projects/recurrence.py`, where + * January 31st and February 29th are decided — and this file owns saying what a + * rule MEANS before somebody commits to it. + * + * **A rule is easier to get wrong than to read back.** "Every 2, weekly, [1,4], + * anchor due" is a shape; "Every 2 weeks on Mon, Thu — keeping to the schedule" + * is a sentence somebody can check. Building that sentence is most of what is + * here, and it is a pure function so the awkward pluralisations have tests. + */ + +export type Freq = "daily" | "weekly" | "monthly" | "yearly"; +export type Anchor = "due" | "completed"; + +/** Mirrors the gateway's `FREQS`. */ +export const FREQS: Freq[] = ["daily", "weekly", "monthly", "yearly"]; + +/** Mirrors the gateway's `ANCHORS`. */ +export const ANCHORS: Anchor[] = ["due", "completed"]; + +export const MAX_INTERVAL = 365; + +export interface Rule { + id?: string; + freq: Freq; + interval: number; + anchor: Anchor; + weekdays: number[]; + day_of_month?: number | null; + month_of_year?: number | null; + until_at?: string | null; + max_occurrences?: number | null; + occurrences_made?: number; +} + +/** ISO weekdays: 1 is Monday, matching the gateway and `Date.getDay()+shift`. */ +export const WEEKDAY_LABELS: Array<[number, string]> = [ + [1, "Mon"], + [2, "Tue"], + [3, "Wed"], + [4, "Thu"], + [5, "Fri"], + [6, "Sat"], + [7, "Sun"], +]; + +const UNIT: Record = { + daily: ["day", "days"], + weekly: ["week", "weeks"], + monthly: ["month", "months"], + yearly: ["year", "years"], +}; + +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +/** `1` → "1st". Used only for a day of the month, so 1–31. */ +export function ordinal(n: number): string { + const tens = n % 100; + if (tens >= 11 && tens <= 13) return `${n}th`; + return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`; +} + +export const emptyRule = (): Rule => ({ + freq: "weekly", + interval: 1, + anchor: "due", + weekdays: [], +}); + +/** + * A rule as a sentence. + * + * The anchor is spelled out rather than named, because "due" and "completed" + * are the two words in this feature that a reader cannot guess the meaning of — + * and choosing the wrong one makes a monthly cadence drift a little later every + * month until nobody trusts the date. + */ +export function describeRule(rule: Rule): string { + const [one, many] = UNIT[rule.freq]; + const every = + rule.interval === 1 ? `Every ${one}` : `Every ${rule.interval} ${many}`; + + let when = every; + if (rule.freq === "weekly" && rule.weekdays.length) { + const days = [...rule.weekdays] + .sort((a, b) => a - b) + .map((d) => WEEKDAY_LABELS.find(([n]) => n === d)?.[1]) + .filter(Boolean); + when = `${every} on ${days.join(", ")}`; + } else if (rule.freq === "monthly" && rule.day_of_month) { + when = `${every} on the ${ordinal(rule.day_of_month)}`; + } else if (rule.freq === "yearly" && rule.day_of_month) { + const month = MONTHS[(rule.month_of_year ?? 1) - 1] ?? ""; + when = `${every} on ${month} ${ordinal(rule.day_of_month)}`.trim(); + } + + const anchor = + rule.anchor === "due" + ? "keeping to the schedule" + : "measured from when it is finished"; + + const limits: string[] = []; + if (rule.max_occurrences) { + const left = rule.max_occurrences - (rule.occurrences_made ?? 0); + // The count LEFT, not the cap: "6 times" beside a series that has run five + // of them reads as five more to come. + limits.push(`${Math.max(0, left)} more time${left === 1 ? "" : "s"}`); + } + if (rule.until_at) { + const until = new Date(rule.until_at); + if (!Number.isNaN(until.getTime())) { + limits.push(`until ${until.toLocaleDateString()}`); + } + } + + return `${when}, ${anchor}${limits.length ? `, ${limits.join(", ")}` : ""}.`; +} + +/** + * Why this rule cannot be saved, or `null`. + * + * Mirrors the gateway's `validate_rule`. The point is not to replace it — the + * server is still the authority — but to say so *before* the round trip, since + * a Save button that can only fail is worse than one that explains itself. + */ +export function ruleProblem(rule: Rule): string | null { + if (!FREQS.includes(rule.freq)) return "Pick how often it repeats."; + if (!Number.isInteger(rule.interval) || rule.interval < 1 || rule.interval > MAX_INTERVAL) { + return `Repeat every 1 to ${MAX_INTERVAL}.`; + } + if (rule.freq === "weekly" && rule.weekdays.length === 0) { + return "Pick at least one day of the week."; + } + if ((rule.freq === "monthly" || rule.freq === "yearly") && !rule.day_of_month) { + return "Pick a day of the month."; + } + return null; +} + +/** Toggle one weekday, keeping the list sorted so the sentence reads in order. */ +export function toggleWeekday(weekdays: number[], day: number): number[] { + const has = weekdays.includes(day); + const next = has ? weekdays.filter((d) => d !== day) : [...weekdays, day]; + return next.sort((a, b) => a - b); +} + +/** + * The rule → the request body. + * + * Fields the chosen frequency does not use are sent as `null` rather than left + * off: a rule edited from monthly to weekly must not keep a stale + * `day_of_month` that would come back the moment somebody switched it again. + */ +export function toPayload(rule: Rule): Record { + const choice = rule.freq; + return { + freq: choice, + interval: rule.interval, + anchor: rule.anchor, + weekdays: choice === "weekly" ? rule.weekdays : [], + day_of_month: + choice === "monthly" || choice === "yearly" ? rule.day_of_month ?? null : null, + month_of_year: choice === "yearly" ? rule.month_of_year ?? 1 : null, + until_at: rule.until_at || null, + max_occurrences: rule.max_occurrences || null, + }; +} From b344c6fb92ff468dc7d398f44cfc792ee4b2de54 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 20:11:37 +0000 Subject: [PATCH 02/22] =?UTF-8?q?feat(WS-27p):=20dependencies=20and=20subt?= =?UTF-8?q?asks,=20made=20reachable=20=E2=80=94=20and=20one=20rule=20nobod?= =?UTF-8?q?y=20had=20written=20down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "pm_task_links and parent_task_id both exist, unreachable from the board. Data with no surface is a promise the product does not keep." GET /projects/tasks/{id}/relations, lib/relations.ts, and a relations block in the task panel. No migration — the tables have been right since 146. BOTH HALVES WERE UNREACHABLE, AND FOR DIFFERENT REASONS. Links could be created and deleted since WS-27a but never LISTED: get_task returns a `links` COUNT and nothing else, so no client could draw one. Subtasks could be created from the panel but never listed either — ?parent_task_id= has existed on the list endpoint since WS-27a and nothing called it. THE RULE NOBODY HAD WRITTEN DOWN: `blocks` may not form a cycle. assert_no_task_cycle has guarded parent_task_id since WS-27a, and the identical hazard sat unguarded on links the whole time. A blocks B blocks C blocks A is a deadlock no human can resolve by finishing something, and every walk over it runs forever. assert_no_block_cycle closes it, bounded by the same MAX_DEPTH its sibling uses, and it TRACKS WHAT IT HAS SEEN — data can already contain a loop, since every link created before the guard existed went in unchecked, and the walk has to terminate over one rather than spin. Only `blocks` is guarded. A cycle in relates_to or duplicates is redundant, not harmful, and refusing one would be a rule with no failure to prevent. BLOCKED-NESS IS DERIVED AND SHOWN, NEVER ENFORCED. A task is blocked when something that blocks it is still open, so a blocker reaching `done` makes the section go quiet — that is how you learn you can start. Refusing to CLOSE a blocked task is the obvious next step and is deliberately not taken: dependencies in a real workspace are frequently approximate, and a tool that will not let somebody finish work they have finished is a tool they route around — after which the links stop being maintained and the feature is worse than absent. VISIBILITY IS APPLIED TO THE CHILDREN, not inherited from the parent. A subtask can be moved into a project the reader cannot see, and listing it because its parent is readable would disclose a title from behind a grant. The live run asserts both that it is absent and that its title does not appear. One endpoint carries both directions, because `blocks` outgoing means "this holds those up" and incoming means "this is waiting" — a client given one side would have to ask twice and would still not know which was which. Blocked by is shown first, since it is the only section that changes what somebody should do next, and empty sections are dropped: six empty headings on every task is how a panel becomes something people scroll past. Progress counts the status CATEGORY rather than completed_at, for the same reason everything else in this app does — a project can name its finished lane "Shipped" or "Signed off", and `cancelled` counts as resolved even though nothing was completed. It reads as "1 of 3" rather than a percentage, because 33% is a worse answer than "1 of 3" to the question people are asking. 21 hermetic + 16 vitest cases, 11 mutants killed and reverted byte-identical, 19 checks against a real Postgres including a subtask and a link in a project the reader has no grant on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../specs/project_management_app.md | 52 +++- ai-company-brain/work_plan.md | 2 +- .../gateway/routes/projects/__init__.py | 1 + .../gateway/routes/projects/relations.py | 240 +++++++++++++++++ .../gateway/gateway/routes/projects/tasks.py | 10 + tests/unit/test_projects_relations.py | 247 +++++++++++++++++ .../projects/components/RelationsBlock.tsx | 251 ++++++++++++++++++ .../src/app/projects/components/TaskPanel.tsx | 22 ++ .../control_plane/src/app/projects/lib/api.ts | 21 ++ .../src/app/projects/lib/relations.test.ts | 162 +++++++++++ .../src/app/projects/lib/relations.ts | 129 +++++++++ .../control_plane/src/app/projects/page.tsx | 3 + 12 files changed, 1138 insertions(+), 2 deletions(-) create mode 100644 apps/services/gateway/gateway/routes/projects/relations.py create mode 100644 tests/unit/test_projects_relations.py create mode 100644 workbench/control_plane/src/app/projects/components/RelationsBlock.tsx create mode 100644 workbench/control_plane/src/app/projects/lib/relations.test.ts create mode 100644 workbench/control_plane/src/app/projects/lib/relations.ts diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index 988c3b3b..b76f8fac 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -946,7 +946,7 @@ interesting it is to build. | 5 | ~~**Tags**~~ | — | **WS-27m ✅ BUILT 2026-08-07** | | 6 | ~~**Bulk edit / multi-select**~~ | — | **WS-27n ✅ BUILT 2026-08-07 · unblocks g** | | 7 | ~~**Recurring tasks**~~ | — | **WS-27o ✅ BUILT 2026-08-07** | -| 8 | **Dependency and subtask UI** — `pm_task_links` and `parent_task_id` both exist, unreachable from the board | Data with no surface is a promise the product does not keep | **WS-27p** | +| 8 | ~~**Dependency and subtask UI**~~ | — | **WS-27p ✅ BUILT 2026-08-07** | | 9 | **Calendar / timeline view** | The third view ClickUp users actually use, after list and board | **WS-27q** | | 10 | **Global task search** | `?q=` exists on the list endpoint; there is no search surface | **WS-27r** | @@ -1559,3 +1559,53 @@ to it — shown live rather than on save, because picking the wrong anchor is in cadence has drifted for three months. The occurrence limit reads as what is **left**, not the cap, and switching frequency clears the fields the new one does not use so a stale `day_of_month` cannot reappear. + +### 11.14 WS-27p — dependencies and subtasks, made reachable (built 2026-08-07) + +*"`pm_task_links` and `parent_task_id` both exist, unreachable from the board. Data with no +surface is a promise the product does not keep."* + +`routes/projects/relations.py` (`GET /projects/tasks/{id}/relations`), `lib/relations.ts` and +a relations block in the task panel. 21 hermetic + 16 vitest cases, 11 mutants red, 19 checks +against a real Postgres. **No migration** — the tables have been right since 146. + +**Both halves were unreachable, and for different reasons.** Links could be *created* and +*deleted* since WS-27a but never **listed**: `get_task` returns a `links` **count** and nothing +else, so no client could draw one. Subtasks could be created from the panel but never listed +either — `?parent_task_id=` has existed on the list endpoint since WS-27a and nothing called +it. What was missing was a way to read them, and one rule nobody had written down. + +**That rule: `blocks` may not form a cycle.** `assert_no_task_cycle` has guarded +`parent_task_id` since WS-27a, and the identical hazard sat unguarded on links the whole time. +A blocks B blocks C blocks A is a deadlock no human can resolve by finishing something, and +every walk over it runs forever. `assert_no_block_cycle` closes it, bounded by the same +`MAX_DEPTH` its sibling uses, and it **tracks what it has seen** — data can already contain a +loop, since every link created before the guard existed went in unchecked, and the walk has to +terminate over one rather than spin. + +**Only `blocks` is guarded.** A cycle in `relates_to` or `duplicates` is redundant, not +harmful, and refusing one would be a rule with no failure to prevent. + +**Blocked-ness is DERIVED and SHOWN, never enforced.** A task is blocked when something that +blocks it is still open, so a blocker reaching `done` makes the section go quiet — that is how +you learn you can start. Refusing to *close* a blocked task is the obvious next step and is +deliberately not taken: dependencies in a real workspace are frequently approximate, and a +tool that will not let somebody finish work they have finished is a tool they route around — +after which the links stop being maintained and the feature is worse than absent. + +**Visibility is applied to the CHILDREN, not inherited from the parent.** A subtask can be +moved into a project the reader cannot see, and listing it because its parent is readable +would disclose a title from behind a grant. The live run asserts a subtask in an ungranted +project is absent *and* that its title does not appear. + +**One endpoint, both directions.** `blocks` outgoing means "this holds those up"; incoming +means "this is waiting". A client given one side would have to ask twice and would still not +know which was which — so each link carries a `direction`, and the browser's `populated()` +turns that into headings, with **Blocked by first** because it is the only section that +changes what somebody should do next. Empty sections are dropped: six empty headings on every +task is how a panel becomes something people scroll past. + +**Progress counts the status CATEGORY**, not `completed_at`, for the same reason everything +else in this app does: a project can name its finished lane "Shipped" or "Signed off", and +`cancelled` counts as resolved even though nothing was completed. It reads as "1 of 3" rather +than a percentage — 33% is a worse answer than "1 of 3" to the question people are asking. diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index 550910ee..4a92a401 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -148,7 +148,7 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`. | WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. | | WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. | | **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT + DEPLOYED** · ✅ **d read half BUILT + DEPLOYED** · ✅ **D2 = d-email BUILT 2026-08-07** (branch `ws-26d-email-timeline`, merged) · 🟢 **d-autolead, d-write dispatchable** · ✅ **D1 = f BUILT 2026-08-07 (branch `ws-26f-pipeline-truth`, NOT run against prod)** · ✅ **D3 = g BUILT 2026-08-07 (branch `ws-26g-reports`, no migration)** · 🟢 **DEMO CRITICAL PATH (owner-directed 2026-08-07, spec §9.0): ~~D1 f~~ (∥ D2 d-email) → ~~D3 g~~ → D4 d-write → D5 d-autolead** · 🟡 **h/i/e deferred past the demo; i spec-thin** | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **migration 144 applied on prod and `/crm` live as of 2026-08-06**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Measured 2026-08-06: mig 145 is applied on prod and the BACKFILL HAS RUN — 737 orgs / 1,189 contacts / 1,516 leads / 551 deals / 1,909 notes, zero dirty rows, zero unmatched owners; the §7.1 pre-flip curl confirmed the tenant honors RFC-1123 `If-Modified-Since` (304). The PUSH direction has still never run: `CRM_ZOHO_SYNC` ships OFF, nothing has ever written the live Zoho tenant, and enabling the flag or hand-running a push cycle against prod stays OWNER-GATE §6.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Deployed:** migrations 144 and 145 are applied on prod as of 2026-08-06 and `/crm` is live, so live rendering, drag persistence and deep links are owner-verifiable now) · **d** integrations — **audited 2026-08-06 GO-NARROWED and the narrowed slice is BUILT** (branch `ws-26d-agent-crm`): `apps/agents/agent-crm/` (`crm-assistant`, MAF, four READ tools over the existing `/crm` routes carrying the caller's `X-User-Email`, read-only enforced at the transport by a GET-only method allowlist) registered in `_KNOWN_AGENTS` + `_AGENT_REGISTRY` + `agent_registry.json`, plus `"crm"` added to the WhatsApp `_KNOWN_SYSTEMS` allowlist **parse-only** (nothing writes `wa_contacts.entity_ref`, the `crm` context block stays `None`, both pinned by test). **The three held-back items are now DISPATCHABLE — their doc blockers (B3/B4/B5/B7) were closed 2026-08-06 in `crm_app.md` §9.1-§9.3, every anchor read off `origin/main` rather than recalled:** **WS-26d-email** (the timeline join is CALLER-scoped, never record-scoped — it reuses the email app's `_account_scope` predicate, copied into `routes/crm/` rather than imported per D-CRM-4, joins by thread not message, inbound `from_address` only, and needs a new address index at the next free migration number) · **WS-26d-autolead** (hook = `routes/email/scheduler_hooks.py::process_new_mail`, the one seam scheduler+manual+webhook all funnel through; the per-message rules loop was considered and REJECTED because a classifier outage there double-fires and history backfills never reach it; unknown-sender test mirrors `_maybe_block_cold`, colleague suppression via `is_own_mail`) · **WS-26d-write** (`request_confirmation` at the top of each tool, fail-closed, no `non_interactive_default="approve"`; `@_annotate_risk` is the shared annotation convention and is NOT enforcement; the Action Broker covers the Zoho push, not the native write — the two fail in OPPOSITE directions and are not interchangeable). The push-queue question is CLOSED — **D-CRM-9 (owner, 2026-08-06): agent-originated writes queue for Zoho exactly like human ones** (🟡 remainder; the flip stays OWNER-GATE) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. **Pipeline blueprint added 2026-08-07 (spec §5.1) after the owner's first live board session found lanes out of order and imported stages at 0% probability — root cause: the importer appends unseen Zoho stages past the seeds at probability 0, and 144's seeds renamed Zoho's defaults so name-match missed them; the admin API that could fix it is headless.** New tickets: **f** pipeline truth + settings UI — **BUILT 2026-08-07** (branch `ws-26f-pipeline-truth`, **no migration**: 144 already carried `position`, `probability`, `type`, `closed_at` and `expected_close_date`). `routes/crm/stage_metadata.py` = `POST /crm/import/zoho/stages`, floor `admin:access:manage`, **dry-run by default** and `?apply=true` to write; **>1 pipeline STOPS before the DB is opened** (D-CRM-11); f4's `closed_at` proxy is a direct UPDATE that bypasses `mark_dirty_on_update`, asserted statically against the statement text AND the module's call graph because the shared fake writes only what a SET clause names. Two settings readers on the Zoho read client with `ZohoScopeError`/`ZohoApiVersionError` so **no-scope, no-data and no-such-endpoint are three different reported outcomes** — and no-scope is the *expected* first answer, since the tenant's refresh token was never minted with `ZohoCRM.settings.*`. D-CRM-10's clamp landed in `admin.py::_validate_status` reading the ROW rather than the payload (a PATCH naming only `type`, or only `probability`, contradicts the rule only in combination with what is stored). `?tab=settings` is the headless-API fix and needs no Zoho token at all. **Nothing has been run against the tenant — dry run included.** · **g** forecast & funnel reports off `crm_status_changes` — **BUILT 2026-08-07** (branch `ws-26g-reports`, **no migration**: 144's `crm_status_changes` already carried every column). `routes/crm/reports.py` = four read-only endpoints (`/crm/reports/{pipeline,funnel,win-loss,owners}`) on the shared gated router, plus `?tab=reports`. **`WEIGHTED_SQL` lifted into `core.py`** (pipeline re-exports it) so the forecast formula has ONE definition, and `core.status_wire` absorbed two duplicate status projections instead of gaining a third. **The cross-language parity mechanism is minted here, not inherited** — the `priority.ts ⟷ priority.py` "precedent" is two hand-kept mirrors joined by a comment with no shared fixture anywhere: `tests/fixtures/crm_weighted_parity.json` (new directory) is read by BOTH pytest (through the emitted SQL, whose expression `_crm_fakes._WEIGHTED_SUM_RE` parses out of the statement text) and vitest (through `board.ts::weightedDeal`/`weightedRows`). ⚠️ **The funnel is defined against what the log RECORDS, not what its name suggests**: `crm_status_changes` logs transitions only, so all 551 imported deals have zero rows and "entered" is a VISITED-SET union (`from_status`, `to_status`, and the deal's current stage) — a `to_status` count reports an empty funnel for the whole live board; dwell is grouped by **`from_status`** (the stage being LEFT, the opposite key from the naive reading); the log stores NAMES, so a renamed lane orphans its history and orphans are REPORTED in `unmatched`, never dropped; and NULL `closed_at` — every imported closed deal until f4's owner-gated backfill runs — falls outside the trailing window, with the count reported so a 0% win rate is explicable rather than mysterious. The lost-reason breakdown carries a NAMED unattributed bucket: the earlier "complete by construction" claim is FALSE, since the importer bypasses both gates and `lost_reason_id` is `ON DELETE SET NULL`. **No `GROUP BY` is emitted** — the ticket asked for the choice to be stated: per-key aggregates in `get_pipeline`'s shape, because the weighted expression binds the lane's own default as `:stage_probability` and a grouped statement would stop BEING the expression the fixture and the fake read. 47 hermetic cases + 20 vitest + the 14-row shared fixture on both sides, 5 mutants red. **Two findings:** the `entity_type='deal'` mutant initially SURVIVED (the funnel keys through deal ids, so a lead row is excluded twice over) — closed with a row stamped `lead` against a DEAL's id, which is realistic precisely because `entity_id` has **no FK**; and `_crm_fakes`' `lower(col) = :param` reader matched a NULL column, which SQL never does, making the unassigned-owner bucket count rows its own aggregate never summed · **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views (🟡 spec-thin, audit-narrow first). **Demo path (2026-08-07, spec §9.0): full chain + all gates intact, tickets re-sequenced not thinned; f gained f4 — imported won/lost deals have no `closed_at` (importer never stamps it), backfilled from Zoho `Closing_Date` as a labeled proxy via a direct UPDATE that MUST bypass dirty-marking or ~500 no-op pushes queue for the live tenant.** | -| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e + i BUILT 2026-08-06 · j + k + l + m + n + o BUILT 2026-08-07** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g — and n is now BUILT (2026-08-07), so that dependency is SATISFIED**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end. g itself stays 🔴 OWNER-GATE for its own reasons (§6); this changes the prerequisite, not the gate · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store).. **j BUILT 2026-08-07** (mig `152_projects_notifications.sql`, `routes/projects/notifications.py`, the header bell + mention picker; 39 hermetic + 27 vitest cases, 10 mutants red): closes §11.2's second gap — *"assignment is silent"*. **Three rules decide who hears**, each the whole reason for a rule: never the actor (a bell that pings you about your own click gets muted, and a muted bell notifies nobody about anything); never an agent (they are handed work by the WS-27f dispatch sink, so a row addressed to one sits unread forever inflating a badge nobody can clear — enforced in Python AND by a CHECK); and **never somebody who cannot open it**, which is the security property: the notification carries the task's TITLE, so delivering one outside the grant closure leaks it and lands them on a 404. That third rule needed `resolve_visibility_for`, which answers for a THIRD PARTY — `resolve_visibility` reads a `UserContext` and the recipient of a mention has no request in flight — by reading the tables `/auth/me` reads and handing them to the **real** `build_access`, so wildcards and allow/deny overrides resolve identically on both paths. Notifications are written **inside the transaction**, not emitted on the bus: `emit` swallows failures by construction so a broken workflow can never fail a task edit, which is right for agent dispatch and wrong here. A mention is an **address, not a name**, because 148 dropped `UNIQUE(name)` — `@Priya` has no answer. **Two bugs found on the way in, both shipping at the time:** every project-task file upload was answering **422** (`ACTIVITY_TYPES` never learned 150's `attachment`; all 25 attachment tests passed because they monkeypatch `record_activity` — the seam under test was mocked out), fixed with two tests that READ the migrations; and `/projects?task=` did nothing, though the People Center has linked there since WS-28b. **WS-27b's UI BUILT 2026-08-07** (`components/ImportClickUp.tsx`, `lib/importPlan.ts`; 18 vitest cases, 3 mutants red): the importer shipped with WS-27b and was **unreachable from the product** — the empty state named "import a ClickUp workspace" and no control anywhere did it, so a new install stayed empty and the only route to real data was curl. Three steps, only the last of which writes: Preview (`/plan`, reads the tenant) → Dry run (`dry_run:true`, exercises the flattening) → Import. **The mapping stays the owner's act (D-PM-10)** and the UI is built so it stays one: the suggestion is pre-filled and shown beside its confidence IN WORDS ("a guess — check it", not `0.45`, because a bare number invites acceptance without looking), and a CONFIRMED mapping always beats a fresh suggestion so a re-run never silently re-maps a Space somebody already ruled on. Unmapped Spaces are a notice rather than a blocker, matching the importer. ⚠️ The gate is unchanged and is now exactly one click: building this was agent-safe, pressing Import is the owner's act, and no agent has run either endpoint against production. **The Tasks-app mirror path BUILT 2026-08-07** (`routes/projects/import_tasks.py`, `POST /import/from-tasks`; 43 hermetic cases, 7 mutants red) — owner-directed: *"just show up all the data that is there in the Tasks app inside the Projects app"*. A SECOND importer rather than a flag: the ClickUp one needs a live token, spends LLM budget and demands a Center mapping BEFORE anything is written, which is backwards for "show me my work today". This reads `gtd_projects`/`gtd_items` — the mirror already on the box — so no API call, no token, no model spend, and it works when the connector is stale. One named department, with the real ClickUp shape beneath it — **Space → Folder → List** rebuilt as projects, each carrying its own `clickup_id`/`clickup_kind`, so promoting a Space node is how the department split happens later. **Verified against a real Postgres** (full migration set + seeded mirror), which found THREE defects the hermetic suite could not: `gtd_projects.space_id` is LOCAL-only so the Space was always NULL; `pm_projects` has no `clickup_snapshot` column, so the first real click would have 500'd (this shipped in #393 and was fixed before anybody pressed it); and the preview under-counted 4 vs 7 because container nodes were only tallied on the write path. The root IS org-granted (same act as `create_node`, narrower than bulk-granting a tenant). Pinned: nothing outside `pm_*` is written; only `source <> 'LOCAL'` rows are read (a personal capture must not be published to a shared board); the provider's own status names are kept; an orphaned task is COUNTED, not dropped. **Mutation found a real gap**: deleting `dry_run` from the write guards left every test green, because on a FIRST dry run `root_id is None` blocks the write anyway — on a SECOND one the department exists and `dry_run` is the only protection. That is the realistic case (preview → import → preview again) and it now has its own test. **k BUILT 2026-08-07** (`routes/projects/filters.py`, `lib/grouping.ts`, `components/FilterBar.tsx`; 34 hermetic + 24 vitest cases, 13 mutants red, and 23 checks against a REAL Postgres) — closes §11.2's third gap, the one whose name was the sentence *"my open bugs in Ops, grouped by assignee"*. **ONE filter builder serves both the list endpoint and saved views**, because a saved view is nothing but a stored set of these filters and two implementations would drift — a *saved* view showing a different set than the same filters typed by hand is the one thing it may not do, so a test compares the two outputs directly. **Every filter is a WHERE clause**: paging happens in SQL, so a filter applied in Python after `LIMIT` returns short pages, and *"page 2 is empty but there are 40 more"* is a bug people work around for months instead of reporting. **`overdue` means past due AND still open** — a finished task with a past due date is done, and permanent red is how a board teaches people to ignore red. **An unknown category is a 422 naming the five real ones**, not an empty board a client reads as "this project is empty". Unknown config **keys** are DROPPED while a bad **value** falls back, because those are different failures: a view is a preference written by an older client, so refusing one over an unrecognised key would make every deploy a migration of everybody's saved views, whereas rendering still has to produce something. On the board, **a task with two assignees appears in BOTH columns** (it is both people's work; picking one hides it from the other, so the header counts tasks not group sizes), **empty status lanes are kept** while every other grouping drops empties (a missing "In progress" column reads as "no such state", not "nothing in progress"), and **dragging is offered only when the columns are statuses** — a drop writes the field the columns represent, and status is the one that is a plain PATCH, so a card that can be dragged into a column which cannot accept it and snaps back is worse than an honestly static column. `toConfig` is deliberately NOT `toQuery`: a query string carries only text so `toQuery` writes `"true"`, and `fromConfig` refuses a string where a toggle belongs, so a view built from query shape would come back with every toggle silently cleared. The project's **order-bearing board is withheld from the chips** — `tree.py` seeds one `board` view per project and it owns every `pm_view_task_positions` row, so offering its ✕ would offer to delete every hand-arranged position; saved views sit at position 300 above the seeded pair and `orderBearingView` is one function used by both the drag handler and the delete guard. **A FIFTH live bug, found the same way as the previous four:** `due_before` was `CAST(:due_before AS timestamptz)` with the raw query-string value — asyncpg infers the parameter's type FROM that cast and then refuses to encode a `str`, so the query never reached Postgres and **`?due_before=…` answered 500** while the hermetic fake, which agrees with whatever SQL it is handed, stayed green. `parse_when` parses on this side and binds a real `datetime`; garbage is a 422 that says what was expected; a naive value is read as UTC rather than inheriting the connection's TimeZone. Two tests — one on the bound value's TYPE, one refusing any `CAST(:param AS timestamp…)` anywhere in the builder — so the next `after=` filter written the obvious way fails in CI instead of in front of a member. **l BUILT 2026-08-07** (mig `155_projects_custom_fields.sql`, `routes/projects/custom_fields.py`, `lib/customFields.ts` + the panel block and the Fields dialog; 47 hermetic + 36 vitest cases, 23 mutants red, 35 checks against a REAL Postgres) — ClickUp's signature feature, and the shape §5's non-goals already recorded as the additive path: **definitions in a table, values denormalised onto the task as JSONB keyed by `field_key`**. NOT a row per (task, field): that is the textbook EAV answer and costs a join per field on every board paint — five fields across two hundred imported tasks is a thousand rows to gather and re-pivot, per render — whereas the JSONB column arrives with the task for free. **The cost is stated rather than discovered**: a value is not referentially tied to its definition, so the DATABASE cannot stop a key no definition owns from being written, and that guarantee moves into Python. Hence the validation IS the feature: an unknown key is a **422 not a silent drop** (a typo that no-ops looks exactly like a save); a patch **MERGES** (a client that knows three of five fields must not wipe the other two — and an older client, or an automation written before a field existed, is precisely that client); an explicit **null CLEARS the key** rather than storing a null, because it is the only way to express "unset this" and a stored null makes "never filled in" and "deliberately emptied" one value in every filter; and **`true` is not the number 1** — `isinstance(True, int)` is True in Python, so the coercers are one-per-type in a dispatch dict specifically so the boolean check can never drift below the number one. **The deliberate departure from Paca:** its research notes record "deleting a definition does not clean task data" as an accepted cost; it is NOT accepted here, because a key left in the JSONB is invisible — no definition means no column, no form row, no filter — until somebody recreates the name and every old value resurfaces carrying the new meaning. The cleared count is reported (R7/R8). Two things a definition may not change once values exist, both because the stored values would stop meaning what they say: **`field_key` is never editable** (it is the identity every value is filed under) and **`field_type` is a 409 naming the count** (text→select cannot re-interpret what is already written); dropping a select option some task holds is refused the same way, adding one is free, and the UI shows the derived key while the name is still being typed since that is the last moment anybody can change it. **Custom fields are REVERTIBLE, which is what makes them first-class:** `patch_task` folds a custom edit into the SAME `field_change` activity under `custom.` rather than inventing an activity type — `record_activity` refuses a type the CHECK does not list, the trap that made every attachment upload answer 422 — and revert restores by **merging onto what the task holds NOW**, never writing back the whole object, since another field may have been edited since and replacing the blob would silently undo that too. **A bug the ticket's own tests caught before it shipped:** `changedValues` compared a form's boolean against a `null` baseline, so a task with an unanswered checkbox sent `open:false` on EVERY save and posted a timeline entry for an edit nobody made — a checkbox has no "unset" state to render, so `false` is its baseline. **And a fence that was quietly a subset check:** `test_projects_routes` asserted a LIST of mounted paths, which catches the module somebody remembered to add a path for; it now also reads the package directory and asserts every module declaring a `@router` route is imported by `__init__.py` — the C1 trap where a missing import mounts nothing while every direct-call test still passes. Verified by deleting the import and watching it fail. **m BUILT 2026-08-07** (mig `156_projects_tags.sql`, `routes/projects/tags.py`, `lib/tags.ts` + the panel picker, the filter row, a `tag` board axis and the Tags dialog; 31 hermetic + 37 vitest cases, 16 mutants red, 30 checks against a REAL Postgres) — the row the research notes left open ON PURPOSE: `paca_pm_research_2026-08.md` row 13 REFUSED Paca's model (*"a bare jsonb string array on tasks. No registry, no colors, no rename/merge — the weakest part of Paca's model"*) and §5 shipped `pm_tasks.tags TEXT[]` in its place with a registry named as additive later. **The array STAYS** — a join table would add a row per tag per task and a join to every board paint, to buy referential integrity this app enforces in one place, whereas the array arrives with the task and its GIN index (146) already answers "tagged X". What the registry buys: **one spelling per tag** (identity is case-INSENSITIVE via a unique index over `lower(name)` so two racing requests cannot create both, display is case-PRESERVING, and the task's array stores the REGISTRY's form — which is what makes "filter by bug finds all of it" true rather than aspirational, and what lets a rename be one statement); **rename**; **merge**; and a **colour**. **Applying an unregistered tag REGISTERS it** — refusing would make tagging a two-step errand (leave the task, create the tag, come back), which is how tagging gets abandoned, and an abandoned tag set is worse than a messy one. **The cost is stated: every typo becomes a tag** — which is exactly why merge is here and is not optional, and why the picker SHOWS the moment of creation rather than minting silently. **A rename onto an existing name is a 409, not a silent merge**: different operations, different outcomes, and one of them destroys a tag — quietly doing the destructive one because the names collided is what stops people using a rename button. **A task carrying BOTH tags ends a merge with the target ONCE** — the case that is easy to get wrong, and getting it wrong leaves a duplicate that renders twice and survives the next merge too; `merged_tags` is pure so that case is asserted directly, and the rewrite runs over affected rows in Python rather than as an `array_replace`, which would leave the duplicate. **TWO tag filters** because both questions get asked and neither answers the other: `tags` is ANY (`&&`) and `tags_all` is ALL (`@>`); collapsing them would silently pick a meaning, and with three tags the answers differ by almost everything. On the board a task with three tags appears in three columns, the same honesty as two assignees appearing in both theirs. **The migration backfills AND rewrites data, deliberately and narrowly:** `tags` has been on `pm_tasks` since 146 and the import path writes them, so an empty registry beside a tagged corpus means the first rename finds nothing; the winning display form is **the spelling people actually use** (most frequent, ties broken deterministically — `min()` alone would canonicalise 400 "Bug" to a single stray "BUG"), and task arrays are then made to agree, only ever swapping one CASING of a tag for another, with the count reported in a NOTICE. **A bug the live run caught in that block:** the canonicalisation used the implicit-comma `FROM pm_tasks t, unnest(t.tags) ... LEFT JOIN` form, where the LEFT JOIN binds only to `unnest(...)` and `t` is not in scope for its ON clause — the migration aborted with "invalid reference to FROM-clause entry for table t". Rewritten as `CROSS JOIN LATERAL … WITH ORDINALITY`, which also fixed a second problem the first version would have shipped: `array_agg(DISTINCT ...)` sorts by its own expression, so every task's tag list would have come back alphabetised. **n BUILT 2026-08-07** (`routes/projects/bulk.py` → `POST /projects/tasks/bulk`, `lib/selection.ts`, `components/BulkBar.tsx` + checkboxes on board and list; 35 hermetic + 32 vitest cases, 16 mutants red, 34 checks against a REAL Postgres; **no migration**) — **the ticket §11.3 names as gating g, so that prerequisite is now satisfied.** It **reuses `automation.apply_task_patch` rather than growing a second writer**: that service exists because WS-27f needed an edit indistinguishable in validation from a human PATCH, and a bulk endpoint with its own field handling would be a third opinion about what a task edit is. Tags go through the same registry the panel uses, because the registry cannot be true if bulk is a second door into the array. **Status is named, never keyed — load-bearing here rather than stylistic**: a selection spans projects and a status id belongs to one root, so `status_id` for fifty tasks across three projects puts two thirds of them in a lane that is not theirs; it is a 422 with its OWN message, because it is the mistake somebody makes by copying a single-task PATCH body and "unknown field" would not explain why the thing that works on one task is refused on fifty. **Assignees and tags are ADD/REMOVE, never SET** — "assign these to Priya" means ALSO Priya, and a replace wipes every individual assignment the fifty tasks already carried; the destructive spelling is absent rather than discouraged. **Shape validated once, outcomes per task**, because those are different failures: an unsettable field is the same mistake for all fifty (422 before any write), while a status name present in one project and absent from another is a fact about THAT task — failing the batch for it makes a mixed selection unusable, which is precisely the selection somebody makes after an import. **An invisible task is SKIPPED, not an error** (R5: per-id reporting says exactly what a per-id 404 says, and aborting would let a caller probe for existence). **One transaction** — a re-triage that half happened is harder to recover from than one that did not, because nobody can tell which half. **Re-asserting a value is not a change** (`moved_people` is pure so the claim is asserted directly): fifty tasks already Priya's would each gain a timeline entry saying nothing, and a count nobody can trust is worse than no count. **ONE notification per person per batch, not one per task** — being handed fifty tasks should ring once and say fifty; fifty bells is a bell people turn off, which is WS-27j's own argument applied to the case that would have broken it. In the browser the selection is **pruned whenever the filter changes** (select forty, narrow to three, press Done must not act on thirty-seven nobody can see), a shift-click ranges over the board's ON-SCREEN order, a two-assignee card drawn in two columns counts once, and the outcome line names every category including the boring ones. **AND IT UNCOVERED A SHIPPED WS-27j BUG** (spec §11.12): `notifications.deliverable` probed only `project_clause('t.root_project_id')` while `core.task_visibility_clause` — whose docstring warns a second implementation "would drift the moment one is edited alone" — carries TWO ways in. So (1) anybody assigned work in a project they hold no grant on was judged undeliverable: they could OPEN the task, the assignment notified nobody, and the response told the assigner they could not see it — the silent assignment WS-27j exists to end, still open for the most common case in a grant-scoped app; and (2) scoping to `root_project_id` ALSO missed a grant made on a SUBPROJECT — the old test asserted that scoping on the argument that "probing `project_id` would miss a grant made on an ancestor", which is BACKWARDS, as a real-Postgres run showed: the closure is recursive and expands DOWNWARD. Fixed to use the shared clause; the test that encoded the wrong reasoning now records why it was wrong. **A fake collision the fix exposed:** `FakeDB` matched the rule-3 probe by substring, and the new clause embeds both `pm_task_assignees` and the closure's `UNION` — exactly the AUDIENCE branch's fingerprint — so `deliverable` got a list of assignees where it expected a visibility answer and four tests failed for a reason unrelated to the code under test. A fake that dispatches on substrings needs fingerprints that are SPECIFIC, not merely present. **o BUILT 2026-08-07** (mig `157_projects_recurrence.sql`, `routes/projects/recurrence.py`, `lib/recurrence.ts` + the repeat row in the task panel; 45 hermetic + 27 vitest cases, 31 mutants red, 39 checks against a REAL Postgres) — **NO SCHEDULER, and that is FORCED rather than chosen**: §5's non-goals say `/workflows` is the only engine (ADR-028/D6), so a recurrence worker here would be exactly the second engine the spec forbids. The successor is created **when a task CLOSES** — `apply_status_transition` already owns that moment, so a task finished from the board, from My work, from an automation or from a bulk edit all recur identically, and a second call site would be a fifth way to finish a task that forgets to. **The cost is stated:** a series only advances when somebody finishes the current one — a monthly report nobody closes does not pile up twelve copies (right), but a daily standup nobody ticks does not appear tomorrow (the honest limitation); materialising ahead is already reachable through the engine that owns scheduling (cron trigger + the `pm_task` node WS-27f added), so nothing needs undoing. **The anchor is PER RULE because the two answers mean different things**: `due` keeps the schedule ("stock count on the 1st" stays on the 1st however late the last was closed, so the series does not drift) and `completed` measures from when the work was actually done ("water the plants every 3 days" restarts when you water them). A `due` anchor also **catches up** — a monthly task closed six weeks late would otherwise produce a successor already overdue the moment it appeared — and the missed occurrences are SKIPPED rather than backfilled, because nobody wants four copies of a standup they did not attend. **The date arithmetic is where this is either right or quietly wrong for a year**, so it is pure and each case is one assertion: January 31st monthly (clamped at COMPUTATION time and stored as asked — storing the clamp permanently demotes the rule to the 28th after its first February), February 29th yearly, "every other Mon and Thu" (within a week it takes the next allowed day and only jumps `interval` weeks when the week runs out; a naive `+14 days` alternates between the two days instead of giving both days of every second week), and a 09:00 standup staying at 09:00. **Closing twice must not spawn twice** — a task can cross into `done` repeatedly (close, reopen to add a note, close again) and every crossing hits the same seam, so `recurrence_spawned_at` guards it and is NEVER cleared: reopening undoes `completed_at` but does not un-emit a successor that may already have been worked on. **Stopping a series keeps the work** (detach, not delete): they are real tasks, some finished, and a button that swept away three months of completed reports is one nobody presses twice. **TWO BUGS THE LIVE RUN CAUGHT AND READING COULD NOT:** (1) the weekly CHECK passed the very row it existed to reject — `array_length('{}', 1)` returns **NULL**, `NULL >= 1` is NULL, and a CHECK only FAILS on false, so a weekly rule with no weekdays inserted happily past a constraint that looked correct; `coalesce(…, 0)` fixes it and a test asserts the coalesce is present, since the hermetic suite has no database to try the expression on; and (2) `_next_number`/`_default_status` were reimplementations, one of which invented a column (`last_number`; the real one is `last_value`) — replaced by `core`'s own `next_task_number` and `load_default_status`, which is the same mistake WS-27n had just been careful to avoid, made two tickets later in the same package. **A third, caught by its own test:** `int(rule.get("interval") or 1)` turns an explicit `0` into "every 1" — a typo that looks exactly like a save, and one the DB CHECK would then have refused as a 500 rather than a 422. In the browser **the SENTENCE is the feature** — a form of five controls is a shape, whereas "Every 2 weeks on Mon, Thu, keeping to the schedule" is something somebody can check before committing, shown LIVE rather than on save because picking the wrong anchor is invisible until a cadence has drifted for three months; the occurrence limit reads as what is LEFT not the cap, and switching frequency clears the fields the new one does not use so a stale `day_of_month` cannot reappear | +| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e + i BUILT 2026-08-06 · j + k + l + m + n + o + p BUILT 2026-08-07** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g — and n is now BUILT (2026-08-07), so that dependency is SATISFIED**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end. g itself stays 🔴 OWNER-GATE for its own reasons (§6); this changes the prerequisite, not the gate · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store).. **j BUILT 2026-08-07** (mig `152_projects_notifications.sql`, `routes/projects/notifications.py`, the header bell + mention picker; 39 hermetic + 27 vitest cases, 10 mutants red): closes §11.2's second gap — *"assignment is silent"*. **Three rules decide who hears**, each the whole reason for a rule: never the actor (a bell that pings you about your own click gets muted, and a muted bell notifies nobody about anything); never an agent (they are handed work by the WS-27f dispatch sink, so a row addressed to one sits unread forever inflating a badge nobody can clear — enforced in Python AND by a CHECK); and **never somebody who cannot open it**, which is the security property: the notification carries the task's TITLE, so delivering one outside the grant closure leaks it and lands them on a 404. That third rule needed `resolve_visibility_for`, which answers for a THIRD PARTY — `resolve_visibility` reads a `UserContext` and the recipient of a mention has no request in flight — by reading the tables `/auth/me` reads and handing them to the **real** `build_access`, so wildcards and allow/deny overrides resolve identically on both paths. Notifications are written **inside the transaction**, not emitted on the bus: `emit` swallows failures by construction so a broken workflow can never fail a task edit, which is right for agent dispatch and wrong here. A mention is an **address, not a name**, because 148 dropped `UNIQUE(name)` — `@Priya` has no answer. **Two bugs found on the way in, both shipping at the time:** every project-task file upload was answering **422** (`ACTIVITY_TYPES` never learned 150's `attachment`; all 25 attachment tests passed because they monkeypatch `record_activity` — the seam under test was mocked out), fixed with two tests that READ the migrations; and `/projects?task=` did nothing, though the People Center has linked there since WS-28b. **WS-27b's UI BUILT 2026-08-07** (`components/ImportClickUp.tsx`, `lib/importPlan.ts`; 18 vitest cases, 3 mutants red): the importer shipped with WS-27b and was **unreachable from the product** — the empty state named "import a ClickUp workspace" and no control anywhere did it, so a new install stayed empty and the only route to real data was curl. Three steps, only the last of which writes: Preview (`/plan`, reads the tenant) → Dry run (`dry_run:true`, exercises the flattening) → Import. **The mapping stays the owner's act (D-PM-10)** and the UI is built so it stays one: the suggestion is pre-filled and shown beside its confidence IN WORDS ("a guess — check it", not `0.45`, because a bare number invites acceptance without looking), and a CONFIRMED mapping always beats a fresh suggestion so a re-run never silently re-maps a Space somebody already ruled on. Unmapped Spaces are a notice rather than a blocker, matching the importer. ⚠️ The gate is unchanged and is now exactly one click: building this was agent-safe, pressing Import is the owner's act, and no agent has run either endpoint against production. **The Tasks-app mirror path BUILT 2026-08-07** (`routes/projects/import_tasks.py`, `POST /import/from-tasks`; 43 hermetic cases, 7 mutants red) — owner-directed: *"just show up all the data that is there in the Tasks app inside the Projects app"*. A SECOND importer rather than a flag: the ClickUp one needs a live token, spends LLM budget and demands a Center mapping BEFORE anything is written, which is backwards for "show me my work today". This reads `gtd_projects`/`gtd_items` — the mirror already on the box — so no API call, no token, no model spend, and it works when the connector is stale. One named department, with the real ClickUp shape beneath it — **Space → Folder → List** rebuilt as projects, each carrying its own `clickup_id`/`clickup_kind`, so promoting a Space node is how the department split happens later. **Verified against a real Postgres** (full migration set + seeded mirror), which found THREE defects the hermetic suite could not: `gtd_projects.space_id` is LOCAL-only so the Space was always NULL; `pm_projects` has no `clickup_snapshot` column, so the first real click would have 500'd (this shipped in #393 and was fixed before anybody pressed it); and the preview under-counted 4 vs 7 because container nodes were only tallied on the write path. The root IS org-granted (same act as `create_node`, narrower than bulk-granting a tenant). Pinned: nothing outside `pm_*` is written; only `source <> 'LOCAL'` rows are read (a personal capture must not be published to a shared board); the provider's own status names are kept; an orphaned task is COUNTED, not dropped. **Mutation found a real gap**: deleting `dry_run` from the write guards left every test green, because on a FIRST dry run `root_id is None` blocks the write anyway — on a SECOND one the department exists and `dry_run` is the only protection. That is the realistic case (preview → import → preview again) and it now has its own test. **k BUILT 2026-08-07** (`routes/projects/filters.py`, `lib/grouping.ts`, `components/FilterBar.tsx`; 34 hermetic + 24 vitest cases, 13 mutants red, and 23 checks against a REAL Postgres) — closes §11.2's third gap, the one whose name was the sentence *"my open bugs in Ops, grouped by assignee"*. **ONE filter builder serves both the list endpoint and saved views**, because a saved view is nothing but a stored set of these filters and two implementations would drift — a *saved* view showing a different set than the same filters typed by hand is the one thing it may not do, so a test compares the two outputs directly. **Every filter is a WHERE clause**: paging happens in SQL, so a filter applied in Python after `LIMIT` returns short pages, and *"page 2 is empty but there are 40 more"* is a bug people work around for months instead of reporting. **`overdue` means past due AND still open** — a finished task with a past due date is done, and permanent red is how a board teaches people to ignore red. **An unknown category is a 422 naming the five real ones**, not an empty board a client reads as "this project is empty". Unknown config **keys** are DROPPED while a bad **value** falls back, because those are different failures: a view is a preference written by an older client, so refusing one over an unrecognised key would make every deploy a migration of everybody's saved views, whereas rendering still has to produce something. On the board, **a task with two assignees appears in BOTH columns** (it is both people's work; picking one hides it from the other, so the header counts tasks not group sizes), **empty status lanes are kept** while every other grouping drops empties (a missing "In progress" column reads as "no such state", not "nothing in progress"), and **dragging is offered only when the columns are statuses** — a drop writes the field the columns represent, and status is the one that is a plain PATCH, so a card that can be dragged into a column which cannot accept it and snaps back is worse than an honestly static column. `toConfig` is deliberately NOT `toQuery`: a query string carries only text so `toQuery` writes `"true"`, and `fromConfig` refuses a string where a toggle belongs, so a view built from query shape would come back with every toggle silently cleared. The project's **order-bearing board is withheld from the chips** — `tree.py` seeds one `board` view per project and it owns every `pm_view_task_positions` row, so offering its ✕ would offer to delete every hand-arranged position; saved views sit at position 300 above the seeded pair and `orderBearingView` is one function used by both the drag handler and the delete guard. **A FIFTH live bug, found the same way as the previous four:** `due_before` was `CAST(:due_before AS timestamptz)` with the raw query-string value — asyncpg infers the parameter's type FROM that cast and then refuses to encode a `str`, so the query never reached Postgres and **`?due_before=…` answered 500** while the hermetic fake, which agrees with whatever SQL it is handed, stayed green. `parse_when` parses on this side and binds a real `datetime`; garbage is a 422 that says what was expected; a naive value is read as UTC rather than inheriting the connection's TimeZone. Two tests — one on the bound value's TYPE, one refusing any `CAST(:param AS timestamp…)` anywhere in the builder — so the next `after=` filter written the obvious way fails in CI instead of in front of a member. **l BUILT 2026-08-07** (mig `155_projects_custom_fields.sql`, `routes/projects/custom_fields.py`, `lib/customFields.ts` + the panel block and the Fields dialog; 47 hermetic + 36 vitest cases, 23 mutants red, 35 checks against a REAL Postgres) — ClickUp's signature feature, and the shape §5's non-goals already recorded as the additive path: **definitions in a table, values denormalised onto the task as JSONB keyed by `field_key`**. NOT a row per (task, field): that is the textbook EAV answer and costs a join per field on every board paint — five fields across two hundred imported tasks is a thousand rows to gather and re-pivot, per render — whereas the JSONB column arrives with the task for free. **The cost is stated rather than discovered**: a value is not referentially tied to its definition, so the DATABASE cannot stop a key no definition owns from being written, and that guarantee moves into Python. Hence the validation IS the feature: an unknown key is a **422 not a silent drop** (a typo that no-ops looks exactly like a save); a patch **MERGES** (a client that knows three of five fields must not wipe the other two — and an older client, or an automation written before a field existed, is precisely that client); an explicit **null CLEARS the key** rather than storing a null, because it is the only way to express "unset this" and a stored null makes "never filled in" and "deliberately emptied" one value in every filter; and **`true` is not the number 1** — `isinstance(True, int)` is True in Python, so the coercers are one-per-type in a dispatch dict specifically so the boolean check can never drift below the number one. **The deliberate departure from Paca:** its research notes record "deleting a definition does not clean task data" as an accepted cost; it is NOT accepted here, because a key left in the JSONB is invisible — no definition means no column, no form row, no filter — until somebody recreates the name and every old value resurfaces carrying the new meaning. The cleared count is reported (R7/R8). Two things a definition may not change once values exist, both because the stored values would stop meaning what they say: **`field_key` is never editable** (it is the identity every value is filed under) and **`field_type` is a 409 naming the count** (text→select cannot re-interpret what is already written); dropping a select option some task holds is refused the same way, adding one is free, and the UI shows the derived key while the name is still being typed since that is the last moment anybody can change it. **Custom fields are REVERTIBLE, which is what makes them first-class:** `patch_task` folds a custom edit into the SAME `field_change` activity under `custom.` rather than inventing an activity type — `record_activity` refuses a type the CHECK does not list, the trap that made every attachment upload answer 422 — and revert restores by **merging onto what the task holds NOW**, never writing back the whole object, since another field may have been edited since and replacing the blob would silently undo that too. **A bug the ticket's own tests caught before it shipped:** `changedValues` compared a form's boolean against a `null` baseline, so a task with an unanswered checkbox sent `open:false` on EVERY save and posted a timeline entry for an edit nobody made — a checkbox has no "unset" state to render, so `false` is its baseline. **And a fence that was quietly a subset check:** `test_projects_routes` asserted a LIST of mounted paths, which catches the module somebody remembered to add a path for; it now also reads the package directory and asserts every module declaring a `@router` route is imported by `__init__.py` — the C1 trap where a missing import mounts nothing while every direct-call test still passes. Verified by deleting the import and watching it fail. **m BUILT 2026-08-07** (mig `156_projects_tags.sql`, `routes/projects/tags.py`, `lib/tags.ts` + the panel picker, the filter row, a `tag` board axis and the Tags dialog; 31 hermetic + 37 vitest cases, 16 mutants red, 30 checks against a REAL Postgres) — the row the research notes left open ON PURPOSE: `paca_pm_research_2026-08.md` row 13 REFUSED Paca's model (*"a bare jsonb string array on tasks. No registry, no colors, no rename/merge — the weakest part of Paca's model"*) and §5 shipped `pm_tasks.tags TEXT[]` in its place with a registry named as additive later. **The array STAYS** — a join table would add a row per tag per task and a join to every board paint, to buy referential integrity this app enforces in one place, whereas the array arrives with the task and its GIN index (146) already answers "tagged X". What the registry buys: **one spelling per tag** (identity is case-INSENSITIVE via a unique index over `lower(name)` so two racing requests cannot create both, display is case-PRESERVING, and the task's array stores the REGISTRY's form — which is what makes "filter by bug finds all of it" true rather than aspirational, and what lets a rename be one statement); **rename**; **merge**; and a **colour**. **Applying an unregistered tag REGISTERS it** — refusing would make tagging a two-step errand (leave the task, create the tag, come back), which is how tagging gets abandoned, and an abandoned tag set is worse than a messy one. **The cost is stated: every typo becomes a tag** — which is exactly why merge is here and is not optional, and why the picker SHOWS the moment of creation rather than minting silently. **A rename onto an existing name is a 409, not a silent merge**: different operations, different outcomes, and one of them destroys a tag — quietly doing the destructive one because the names collided is what stops people using a rename button. **A task carrying BOTH tags ends a merge with the target ONCE** — the case that is easy to get wrong, and getting it wrong leaves a duplicate that renders twice and survives the next merge too; `merged_tags` is pure so that case is asserted directly, and the rewrite runs over affected rows in Python rather than as an `array_replace`, which would leave the duplicate. **TWO tag filters** because both questions get asked and neither answers the other: `tags` is ANY (`&&`) and `tags_all` is ALL (`@>`); collapsing them would silently pick a meaning, and with three tags the answers differ by almost everything. On the board a task with three tags appears in three columns, the same honesty as two assignees appearing in both theirs. **The migration backfills AND rewrites data, deliberately and narrowly:** `tags` has been on `pm_tasks` since 146 and the import path writes them, so an empty registry beside a tagged corpus means the first rename finds nothing; the winning display form is **the spelling people actually use** (most frequent, ties broken deterministically — `min()` alone would canonicalise 400 "Bug" to a single stray "BUG"), and task arrays are then made to agree, only ever swapping one CASING of a tag for another, with the count reported in a NOTICE. **A bug the live run caught in that block:** the canonicalisation used the implicit-comma `FROM pm_tasks t, unnest(t.tags) ... LEFT JOIN` form, where the LEFT JOIN binds only to `unnest(...)` and `t` is not in scope for its ON clause — the migration aborted with "invalid reference to FROM-clause entry for table t". Rewritten as `CROSS JOIN LATERAL … WITH ORDINALITY`, which also fixed a second problem the first version would have shipped: `array_agg(DISTINCT ...)` sorts by its own expression, so every task's tag list would have come back alphabetised. **n BUILT 2026-08-07** (`routes/projects/bulk.py` → `POST /projects/tasks/bulk`, `lib/selection.ts`, `components/BulkBar.tsx` + checkboxes on board and list; 35 hermetic + 32 vitest cases, 16 mutants red, 34 checks against a REAL Postgres; **no migration**) — **the ticket §11.3 names as gating g, so that prerequisite is now satisfied.** It **reuses `automation.apply_task_patch` rather than growing a second writer**: that service exists because WS-27f needed an edit indistinguishable in validation from a human PATCH, and a bulk endpoint with its own field handling would be a third opinion about what a task edit is. Tags go through the same registry the panel uses, because the registry cannot be true if bulk is a second door into the array. **Status is named, never keyed — load-bearing here rather than stylistic**: a selection spans projects and a status id belongs to one root, so `status_id` for fifty tasks across three projects puts two thirds of them in a lane that is not theirs; it is a 422 with its OWN message, because it is the mistake somebody makes by copying a single-task PATCH body and "unknown field" would not explain why the thing that works on one task is refused on fifty. **Assignees and tags are ADD/REMOVE, never SET** — "assign these to Priya" means ALSO Priya, and a replace wipes every individual assignment the fifty tasks already carried; the destructive spelling is absent rather than discouraged. **Shape validated once, outcomes per task**, because those are different failures: an unsettable field is the same mistake for all fifty (422 before any write), while a status name present in one project and absent from another is a fact about THAT task — failing the batch for it makes a mixed selection unusable, which is precisely the selection somebody makes after an import. **An invisible task is SKIPPED, not an error** (R5: per-id reporting says exactly what a per-id 404 says, and aborting would let a caller probe for existence). **One transaction** — a re-triage that half happened is harder to recover from than one that did not, because nobody can tell which half. **Re-asserting a value is not a change** (`moved_people` is pure so the claim is asserted directly): fifty tasks already Priya's would each gain a timeline entry saying nothing, and a count nobody can trust is worse than no count. **ONE notification per person per batch, not one per task** — being handed fifty tasks should ring once and say fifty; fifty bells is a bell people turn off, which is WS-27j's own argument applied to the case that would have broken it. In the browser the selection is **pruned whenever the filter changes** (select forty, narrow to three, press Done must not act on thirty-seven nobody can see), a shift-click ranges over the board's ON-SCREEN order, a two-assignee card drawn in two columns counts once, and the outcome line names every category including the boring ones. **AND IT UNCOVERED A SHIPPED WS-27j BUG** (spec §11.12): `notifications.deliverable` probed only `project_clause('t.root_project_id')` while `core.task_visibility_clause` — whose docstring warns a second implementation "would drift the moment one is edited alone" — carries TWO ways in. So (1) anybody assigned work in a project they hold no grant on was judged undeliverable: they could OPEN the task, the assignment notified nobody, and the response told the assigner they could not see it — the silent assignment WS-27j exists to end, still open for the most common case in a grant-scoped app; and (2) scoping to `root_project_id` ALSO missed a grant made on a SUBPROJECT — the old test asserted that scoping on the argument that "probing `project_id` would miss a grant made on an ancestor", which is BACKWARDS, as a real-Postgres run showed: the closure is recursive and expands DOWNWARD. Fixed to use the shared clause; the test that encoded the wrong reasoning now records why it was wrong. **A fake collision the fix exposed:** `FakeDB` matched the rule-3 probe by substring, and the new clause embeds both `pm_task_assignees` and the closure's `UNION` — exactly the AUDIENCE branch's fingerprint — so `deliverable` got a list of assignees where it expected a visibility answer and four tests failed for a reason unrelated to the code under test. A fake that dispatches on substrings needs fingerprints that are SPECIFIC, not merely present. **o BUILT 2026-08-07** (mig `157_projects_recurrence.sql`, `routes/projects/recurrence.py`, `lib/recurrence.ts` + the repeat row in the task panel; 45 hermetic + 27 vitest cases, 31 mutants red, 39 checks against a REAL Postgres) — **NO SCHEDULER, and that is FORCED rather than chosen**: §5's non-goals say `/workflows` is the only engine (ADR-028/D6), so a recurrence worker here would be exactly the second engine the spec forbids. The successor is created **when a task CLOSES** — `apply_status_transition` already owns that moment, so a task finished from the board, from My work, from an automation or from a bulk edit all recur identically, and a second call site would be a fifth way to finish a task that forgets to. **The cost is stated:** a series only advances when somebody finishes the current one — a monthly report nobody closes does not pile up twelve copies (right), but a daily standup nobody ticks does not appear tomorrow (the honest limitation); materialising ahead is already reachable through the engine that owns scheduling (cron trigger + the `pm_task` node WS-27f added), so nothing needs undoing. **The anchor is PER RULE because the two answers mean different things**: `due` keeps the schedule ("stock count on the 1st" stays on the 1st however late the last was closed, so the series does not drift) and `completed` measures from when the work was actually done ("water the plants every 3 days" restarts when you water them). A `due` anchor also **catches up** — a monthly task closed six weeks late would otherwise produce a successor already overdue the moment it appeared — and the missed occurrences are SKIPPED rather than backfilled, because nobody wants four copies of a standup they did not attend. **The date arithmetic is where this is either right or quietly wrong for a year**, so it is pure and each case is one assertion: January 31st monthly (clamped at COMPUTATION time and stored as asked — storing the clamp permanently demotes the rule to the 28th after its first February), February 29th yearly, "every other Mon and Thu" (within a week it takes the next allowed day and only jumps `interval` weeks when the week runs out; a naive `+14 days` alternates between the two days instead of giving both days of every second week), and a 09:00 standup staying at 09:00. **Closing twice must not spawn twice** — a task can cross into `done` repeatedly (close, reopen to add a note, close again) and every crossing hits the same seam, so `recurrence_spawned_at` guards it and is NEVER cleared: reopening undoes `completed_at` but does not un-emit a successor that may already have been worked on. **Stopping a series keeps the work** (detach, not delete): they are real tasks, some finished, and a button that swept away three months of completed reports is one nobody presses twice. **TWO BUGS THE LIVE RUN CAUGHT AND READING COULD NOT:** (1) the weekly CHECK passed the very row it existed to reject — `array_length('{}', 1)` returns **NULL**, `NULL >= 1` is NULL, and a CHECK only FAILS on false, so a weekly rule with no weekdays inserted happily past a constraint that looked correct; `coalesce(…, 0)` fixes it and a test asserts the coalesce is present, since the hermetic suite has no database to try the expression on; and (2) `_next_number`/`_default_status` were reimplementations, one of which invented a column (`last_number`; the real one is `last_value`) — replaced by `core`'s own `next_task_number` and `load_default_status`, which is the same mistake WS-27n had just been careful to avoid, made two tickets later in the same package. **A third, caught by its own test:** `int(rule.get("interval") or 1)` turns an explicit `0` into "every 1" — a typo that looks exactly like a save, and one the DB CHECK would then have refused as a 500 rather than a 422. In the browser **the SENTENCE is the feature** — a form of five controls is a shape, whereas "Every 2 weeks on Mon, Thu, keeping to the schedule" is something somebody can check before committing, shown LIVE rather than on save because picking the wrong anchor is invisible until a cadence has drifted for three months; the occurrence limit reads as what is LEFT not the cap, and switching frequency clears the fields the new one does not use so a stale `day_of_month` cannot reappear. **p BUILT 2026-08-07** (`routes/projects/relations.py` → `GET /tasks/{id}/relations`, `lib/relations.ts` + the relations block in the panel; 21 hermetic + 16 vitest cases, 11 mutants red, 19 checks against a REAL Postgres; **no migration**) — closes *"data with no surface is a promise the product does not keep"*. **BOTH halves were genuinely unreachable, for different reasons:** links could be CREATED and DELETED since WS-27a but never LISTED (`get_task` returns a *count*), and subtasks could be created from the panel but never listed either (`?parent_task_id=` existed and nothing called it). What was missing was a way to read them and **one rule nobody had written down: `blocks` may not form a cycle.** `assert_no_task_cycle` has guarded `parent_task_id` since WS-27a and the identical hazard sat unguarded on links — A blocks B blocks C blocks A is a deadlock no human can resolve by finishing something, and every walk over it runs forever. The new guard is bounded by the same `MAX_DEPTH` and **tracks what it has seen**, because data can ALREADY contain a loop (every link created before the guard went in unchecked) and the walk must terminate over one rather than spin. **Only `blocks` is guarded** — a cycle in `relates_to` is redundant, not harmful, and refusing one would be a rule with no failure to prevent. **Blocked-ness is DERIVED and SHOWN, never ENFORCED**: refusing to close a blocked task is the obvious next step and is deliberately not taken, because dependencies in a real workspace are approximate and a tool that will not let somebody finish work they have finished is one they route around — after which the links stop being maintained and the feature is worse than absent. **Visibility is applied to the CHILDREN, not inherited from the parent**: a subtask can be moved into a project the reader cannot see, and listing it because its parent is readable would disclose a title from behind a grant (the live run asserts both the absence and that the title does not appear). ONE endpoint carries BOTH directions, because `blocks` outgoing means "this holds those up" and incoming means "this is waiting" — a client given one side would ask twice and still not know which was which; **Blocked by is shown FIRST** since it is the only section that changes what to do next, and empty sections are dropped because six empty headings on every task is how a panel becomes something people scroll past. Progress counts the status CATEGORY not `completed_at` (a project can name its finished lane anything, and `cancelled` is resolved), and reads as "1 of 3" rather than 33% | | **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | ✅ **a + b BUILT 2026-08-06 · b-write BUILT 2026-08-07** · 🟢 c–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE).. **a BUILT 2026-08-06** (mig `148_people_key_shape.sql` + `scripts/import_hr_people.py`; 22 cases, 11 mutants red, 1 equivalent): `UNIQUE(name)` dropped, partial unique on `lower(email)`, status CHECK. **P-1 did not name its own consequence** — the HR importer upserts `ON CONFLICT (name)` and would have failed outright, so a `source_key` (`:`) carries the upsert instead, backfilled BEFORE the constraint is dropped while `name` is still distinct. **Neither new constraint may block a deploy** (main was bitten twice this month): a duplicate address is quarantined into a new `email_conflict` column with a deterministic winner rather than failing `CREATE UNIQUE INDEX`, and the status CHECK is added `NOT VALID` then validated in a guarded block, so an unanticipated legacy value leaves a NOTICE instead of stopping the deploy. ⚠️ `schema.generated.sql` NOT refreshed — needs a live DB; regenerate on the first deploy that applies 148. **b BUILT 2026-08-06** (mig `149_people.sql`, `routes/people/`, `src/app/people/`; 32 hermetic + 28 vitest cases, 11 mutants red): `/people` directory, person page with all four panels, and the People Center's "Directory & org chart" sub-app flipped live — closing WS-13's outstanding read view. **Its own feature slug**, not `feature:tasks`: a manager who needs the org chart should not be handed the personal GTD task manager to get it. The gate is new but the HR **projection is imported** from `tasks.core` and a test asserts the function's *identity*, since two answers to "may this caller see skills" are two answers waiting to drift. Three filters (the `q` skills clause, `skill`, `has_capacity`) are dropped without `admin:members:read` so search cannot become an oracle for the hidden field — and the response carries `hr_visible` so the UI says "restricted" rather than leaving a blank strip to read as "nobody filled it in". Load is **computed from open assigned tasks** and carries `unestimated`, because a bar built from the estimate sum alone shows somebody holding thirty un-estimated tasks as completely free. The work panel is scoped by the **viewer's** grants and answers `available:false` without `feature:projects`. **Registration is FIVE places, not four** — the fifth is `test_org_access_enforcement.GATED_ROUTERS`, hand-maintained, where an absent router is unchecked rather than passing; also added the named `test_projects_is_registered_on_both_sides` that WS-27a never wrote. **b-write BUILT 2026-08-07** (`people/components/PersonEditor.tsx`, `people/lib/form.ts`, `people/lib/write.ts`; 26 hermetic + 23 vitest cases, 7 mutants red): closes the regression the 2026-08-06 scope narrowing opened — deleting the tasks app's People view took `PersonEditor` with it, and with it the only UI for creating a person, editing skills and uploading a résumé. The GET-only `/api/people` proxy is **unchanged**: the writes go to `/api/tasks/people`, where they have always lived. Controls are absent rather than disabled, driven by a new **`can_manage`** flag on the reads — hide-rather-than-disable is impossible unless the read tells the UI, and the alternative is drawing the button and letting the click find a 403. **Restoring it turned up three ways migration 148 had already broken the write routes**, each a 500 in front of an admin rather than a test failure: the status vocabulary moved (49's `inactive`/`on_leave` vs 148's CHECK) and is now ONE tuple shared by filter, facets, select and validation; `create_person` still refused a duplicate NAME, preserving precisely the behaviour 148 dropped `UNIQUE(name)` to remove; and nothing checked the address 148 made unique, so a duplicate was an `IntegrityError` instead of a 409 naming the other row. **The lesson recorded in spec §10:** a migration that changes a table's shape has to be walked against every route that WRITES it — the read routes were built after 148 and were correct by construction, the write routes predated it and were never revisited | --- diff --git a/apps/services/gateway/gateway/routes/projects/__init__.py b/apps/services/gateway/gateway/routes/projects/__init__.py index 9a53a4c0..8c2a5b2d 100644 --- a/apps/services/gateway/gateway/routes/projects/__init__.py +++ b/apps/services/gateway/gateway/routes/projects/__init__.py @@ -32,6 +32,7 @@ from gateway.routes.projects import notifications as _notifications # noqa: F401 from gateway.routes.projects import personal as _personal # noqa: F401 from gateway.routes.projects import recurrence as _recurrence # noqa: F401 +from gateway.routes.projects import relations as _relations # noqa: F401 from gateway.routes.projects import tags as _tags # noqa: F401 from gateway.routes.projects import tasks as _tasks # noqa: F401 from gateway.routes.projects import tree as _tree # noqa: F401 diff --git a/apps/services/gateway/gateway/routes/projects/relations.py b/apps/services/gateway/gateway/routes/projects/relations.py new file mode 100644 index 00000000..f09ed7f7 --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/relations.py @@ -0,0 +1,240 @@ +"""Projects · dependencies and subtasks, made reachable (WS-27p). + +Spec: ``ai-company-brain/specs/project_management_app.md`` §11.2 item 8, §11.14. + + GET /projects/tasks/{task_id}/relations → subtasks + links, both directions + +*"`pm_task_links` and `parent_task_id` both exist, unreachable from the board. +Data with no surface is a promise the product does not keep."* + +**Both halves were genuinely unreachable, and for different reasons.** Links +could be created and deleted since WS-27a but never LISTED — `get_task` returns +a `links` *count* and nothing else, so no client could draw one. Subtasks could +be created from the panel but never listed either: `?parent_task_id=` exists on +the list endpoint and nothing called it. + +**No migration.** The tables have been right since 146; what was missing was a +way to read them and one rule nobody had written down. + +**That rule: `blocks` may not form a cycle.** `assert_no_task_cycle` has guarded +`parent_task_id` since WS-27a, and the same hazard sat unguarded on links — A +blocks B blocks C blocks A is a deadlock no human can resolve by finishing +something, and any "is this blocked" walk over it does not terminate. + +**Blocked-ness is DERIVED and SHOWN, never enforced.** A task is blocked when +something that blocks it is still open. Refusing to close a blocked task is the +obvious next step and is deliberately not taken: dependencies in a real +workspace are frequently approximate, and a tool that will not let somebody +finish work they have finished is a tool they route around — after which the +links stop being maintained and the feature is worse than absent. +""" + +from __future__ import annotations + +from typing import Any + +from acb_auth import UserContext, get_current_user +from fastapi import Depends, HTTPException +from gateway.routes.projects.core import ( + CLOSING_CATEGORIES, + MAX_DEPTH, + _get_db, + load_visible_task, + resolve_visibility, + router, + task_visibility_clause, + wire, +) +from sqlalchemy import text + +LINK_TYPES: tuple[str, ...] = ("blocks", "relates_to", "duplicates") + +#: The only link type with a direction that means anything to scheduling, and so +#: the only one a cycle can deadlock. `relates_to` and `duplicates` are +#: associations — a cycle in them is redundant, not harmful, and refusing one +#: would be a rule with no failure to prevent. +DIRECTED_TYPES: tuple[str, ...] = ("blocks",) + + +def blocked_by_open(blockers: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Of the tasks blocking this one, those that are still open. + + Pure, and separate from the query, because "blocked" is a derived word this + app now shows in three places and it must mean the same thing in all of + them: a blocker that is `done` or `cancelled` no longer blocks anything. + """ + return [b for b in blockers if b.get("category") not in CLOSING_CATEGORIES] + + +def subtask_progress(children: list[dict[str, Any]]) -> dict[str, int]: + """``{done, total}`` for a set of subtasks. + + Counted from the child's status CATEGORY rather than from `completed_at`, + for the same reason everything else in this app keys off the category: a + project can name its finished lane anything, and `cancelled` counts as + resolved even though nothing was completed. + """ + total = len(children) + done = sum(1 for c in children if c.get("category") in CLOSING_CATEGORIES) + return {"done": done, "total": total} + + +async def assert_no_block_cycle(db: Any, source_id: str, target_id: str) -> None: + """Refuse a ``blocks`` link that would close a loop. + + Walks forward from the proposed target: if the chain of things *it* blocks + ever reaches the source, the new link would complete a cycle. + + The same hazard `assert_no_task_cycle` guards on `parent_task_id`, and it + was unguarded here — A blocks B blocks C blocks A is a deadlock no human can + resolve by finishing something, and every walk over it runs forever. + + Bounded by `MAX_DEPTH` like its sibling: a chain longer than that is already + a chain nobody is reading, and an unbounded walk over data somebody can + create is a denial-of-service surface rather than a thorough check. + """ + if str(source_id) == str(target_id): + raise HTTPException( + status_code=422, detail="A task cannot block itself.", + ) + frontier = {str(target_id)} + seen: set[str] = set() + for _ in range(MAX_DEPTH): + if not frontier: + return + if str(source_id) in frontier: + raise HTTPException( + status_code=422, + detail="That link would make a loop: this task already depends " + "on the one you are blocking, so neither could ever " + "start.", + ) + seen |= frontier + rows = (await db.execute( + text( + "SELECT target_task_id FROM pm_task_links " + " WHERE link_type = 'blocks' " + " AND source_task_id = ANY(CAST(:ids AS uuid[]))" + ), + {"ids": sorted(frontier)}, + )).fetchall() + frontier = {str(r.target_task_id) for r in rows} - seen + raise HTTPException( + status_code=422, + detail="This dependency chain is longer than the supported maximum.", + ) + + +#: Subtasks, with the one status field the panel needs to draw progress. +#: +#: Visibility is applied to the CHILDREN, not inherited from the parent. A +#: subtask can be moved into a project the reader cannot see, and listing it +#: because its parent is readable would disclose a title from behind a grant. +_SUBTASKS_SQL = """ +SELECT t.id, t.title, t.task_number, t.status_id, t.completed_at, + s.name AS status_name, s.category + FROM pm_tasks t + JOIN pm_task_statuses s ON s.id = t.status_id + WHERE t.parent_task_id = CAST(:tid AS uuid) + AND t.archived_at IS NULL + AND {visible} + ORDER BY t.task_number NULLS LAST, t.created_at +""" + +#: Links in BOTH directions, in one query. +#: +#: `direction` says which end this task is on, because the two read completely +#: differently: `blocks` outgoing means "this holds those up", incoming means +#: "this is waiting". A client given only one side would have to ask twice and +#: would still not know which was which. +_LINKS_SQL = """ +SELECT l.id, l.link_type, 'outgoing' AS direction, + t.id AS other_id, t.title, t.task_number, t.completed_at, + s.name AS status_name, s.category + FROM pm_task_links l + JOIN pm_tasks t ON t.id = l.target_task_id + JOIN pm_task_statuses s ON s.id = t.status_id + WHERE l.source_task_id = CAST(:tid AS uuid) AND {visible} +UNION ALL +SELECT l.id, l.link_type, 'incoming' AS direction, + t.id AS other_id, t.title, t.task_number, t.completed_at, + s.name AS status_name, s.category + FROM pm_task_links l + JOIN pm_tasks t ON t.id = l.source_task_id + JOIN pm_task_statuses s ON s.id = t.status_id + WHERE l.target_task_id = CAST(:tid AS uuid) AND {visible} +""" + + +def _row(row: Any) -> dict[str, Any]: + return { + "id": str(row.other_id), + "link_id": str(row.id), + "link_type": row.link_type, + "direction": row.direction, + "title": row.title, + "task_number": row.task_number, + "status_name": row.status_name, + "category": row.category, + "completed_at": wire(row.completed_at), + } + + +@router.get("/tasks/{task_id}/relations") +async def get_relations( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """One task's subtasks and links, with enough of each to render. + + ONE endpoint rather than three, because the panel needs all of it at once + and three round trips to fill one block is three chances to paint a + half-drawn dependency section. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + visible = task_visibility_clause(vis) + + children = [ + { + "id": str(r.id), "title": r.title, "task_number": r.task_number, + "status_id": str(r.status_id), "status_name": r.status_name, + "category": r.category, "completed_at": wire(r.completed_at), + } + for r in (await db.execute( + text(_SUBTASKS_SQL.format(visible=visible)), + {"tid": task_id, **vis.params}, + )).fetchall() + ] + + links = [ + _row(r) for r in (await db.execute( + text(_LINKS_SQL.format(visible=visible)), + {"tid": task_id, **vis.params}, + )).fetchall() + ] + + # "Blocked by" is the INCOMING half of `blocks`: somebody else's task + # names this one as the thing it holds up. + blockers = [ + link for link in links + if link["link_type"] == "blocks" and link["direction"] == "incoming" + ] + return { + "subtasks": children, + "progress": subtask_progress(children), + "links": links, + "blocked_by": blocked_by_open(blockers), + } + finally: + await db.close() + + +__all__ = [ + "DIRECTED_TYPES", + "LINK_TYPES", + "assert_no_block_cycle", + "blocked_by_open", + "subtask_progress", +] diff --git a/apps/services/gateway/gateway/routes/projects/tasks.py b/apps/services/gateway/gateway/routes/projects/tasks.py index a428d85d..e2a9ae86 100644 --- a/apps/services/gateway/gateway/routes/projects/tasks.py +++ b/apps/services/gateway/gateway/routes/projects/tasks.py @@ -62,6 +62,10 @@ build_task_filters, ) from gateway.routes.projects.notifications import notify +from gateway.routes.projects.relations import ( + DIRECTED_TYPES, + assert_no_block_cycle, +) from gateway.routes.projects.tags import apply_task_tags from pydantic import BaseModel from sqlalchemy import text @@ -618,6 +622,12 @@ async def create_link( # Both ends must be visible: a link is readable from either side, so # accepting an unreadable target would disclose that it exists. await load_visible_task(db, vis, str(payload.target_task_id)) + # WS-27p — the same guard `assert_no_task_cycle` has always put on + # `parent_task_id`, finally on the edge that can actually deadlock: + # A blocks B blocks C blocks A is a loop no human can resolve by + # finishing something, and every walk over it runs forever. + if payload.link_type in DIRECTED_TYPES: + await assert_no_block_cycle(db, task_id, str(payload.target_task_id)) row = (await db.execute( text( "INSERT INTO pm_task_links " diff --git a/tests/unit/test_projects_relations.py b/tests/unit/test_projects_relations.py new file mode 100644 index 00000000..e32d2d12 --- /dev/null +++ b/tests/unit/test_projects_relations.py @@ -0,0 +1,247 @@ +"""WS-27p — dependencies and subtasks, made reachable. + +Spec: `ai-company-brain/specs/project_management_app.md` §11.2 item 8, §11.14. + +*"`pm_task_links` and `parent_task_id` both exist, unreachable from the board. +Data with no surface is a promise the product does not keep."* + +Both halves were genuinely unreachable, and for different reasons: links could +be created and deleted since WS-27a but never LISTED (`get_task` returns a +*count*), and subtasks could be created from the panel but never listed either. + +The claims worth pinning: + +* **`blocks` may not form a cycle.** `assert_no_task_cycle` has guarded + `parent_task_id` since WS-27a and the same hazard sat unguarded on links. A + blocks B blocks C blocks A is a deadlock no human can resolve by finishing + something, and every walk over it runs forever. +* **only `blocks` is guarded.** A cycle in `relates_to` is redundant, not + harmful, and refusing one would be a rule with no failure to prevent. +* **"blocked" means a blocker that is still OPEN.** A done blocker blocks + nothing, and a task that stays red after its dependency shipped is a task + people learn to ignore. +* **progress counts the CATEGORY**, not `completed_at` — a project can name its + finished lane anything, and `cancelled` is resolved even though nothing was + completed. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest +from fastapi import HTTPException +from gateway.routes.projects.core import CLOSING_CATEGORIES, MAX_DEPTH +from gateway.routes.projects.relations import ( + DIRECTED_TYPES, + LINK_TYPES, + assert_no_block_cycle, + blocked_by_open, + subtask_progress, +) + + +def run(coro): + import asyncio + + return asyncio.run(coro) + + +class FakeLinks: + """Just enough of a db for the cycle walk: a `blocks` adjacency list.""" + + def __init__(self, edges: dict[str, list[str]]): + self.edges = edges + self.queries = 0 + + async def execute(self, sql: Any, params: dict | None = None): + self.queries += 1 + ids = (params or {}).get("ids") or [] + out: list[Any] = [] + for source in ids: + for target in self.edges.get(str(source), []): + out.append(SimpleNamespace(target_task_id=target)) + return SimpleNamespace(fetchall=lambda: out) + + +# ── The vocabulary ────────────────────────────────────────────────────────── + +def test_only_blocks_is_treated_as_directed(): + """A cycle in `relates_to` or `duplicates` is redundant, not harmful. + Refusing one would be a rule with no failure to prevent.""" + assert DIRECTED_TYPES == ("blocks",) + for kind in DIRECTED_TYPES: + assert kind in LINK_TYPES + + +def test_the_link_types_match_the_ones_tasks_py_accepts(): + """Two lists of link types would drift, and the pair that drifted would let + a link be created that this module refuses to classify.""" + from gateway.routes.projects.tasks import _LINK_TYPES + + assert set(LINK_TYPES) == set(_LINK_TYPES) + + +# ── The cycle guard ───────────────────────────────────────────────────────── + +def test_a_task_cannot_block_itself_and_is_TOLD_that(): + """Without its own branch this still 422s — the walk's first step finds the + source in the frontier — but it says "this task already depends on the one + you are blocking", which is a baffling thing to read about one task.""" + with pytest.raises(HTTPException) as exc: + run(assert_no_block_cycle(FakeLinks({}), "a", "a")) + assert exc.value.status_code == 422 + assert "itself" in str(exc.value.detail), ( + "a self-link deserves its own message, not the loop explanation" + ) + + +def test_a_direct_reciprocal_block_is_refused(): + """B already blocks A, so A blocking B closes the loop.""" + with pytest.raises(HTTPException) as exc: + run(assert_no_block_cycle(FakeLinks({"b": ["a"]}), "a", "b")) + assert exc.value.status_code == 422 + assert "loop" in str(exc.value.detail) + + +def test_a_LONG_cycle_is_refused_too(): + """The case a naive one-hop check misses: A → B → C → A.""" + with pytest.raises(HTTPException): + run(assert_no_block_cycle(FakeLinks({"b": ["c"], "c": ["a"]}), "a", "b")) + + +def test_an_honest_chain_is_allowed(): + """A → B → C is a dependency chain, not a cycle, and refusing it would make + the feature useless for the thing it is for.""" + run(assert_no_block_cycle(FakeLinks({"b": ["c"]}), "a", "b")) + + +def test_a_diamond_is_allowed(): + """A blocks B and C, both of which block D. Not a cycle — and a walk that + did not track what it had seen would visit D twice.""" + run(assert_no_block_cycle(FakeLinks({"b": ["d"], "c": ["d"]}), "a", "b")) + + +def test_an_existing_cycle_elsewhere_does_not_hang_the_walk(): + """Data can already contain a loop — 146 has no constraint against one, and + every link created before this guard existed went in unchecked. The walk + must terminate over it rather than spin.""" + graph = FakeLinks({"b": ["c"], "c": ["b"]}) + run(assert_no_block_cycle(graph, "a", "b")) + assert graph.queries < MAX_DEPTH, "the walk revisited nodes instead of tracking them" + + +def test_the_walk_is_bounded(): + """An unbounded walk over data somebody can create is a denial-of-service + surface rather than a thorough check.""" + chain = {str(i): [str(i + 1)] for i in range(MAX_DEPTH + 50)} + with pytest.raises(HTTPException) as exc: + run(assert_no_block_cycle(FakeLinks(chain), "target", "0")) + assert "longer than the supported maximum" in str(exc.value.detail) + + +def test_the_walk_asks_in_BATCHES_not_one_query_per_node(): + """A dependency graph is walked on every link create. Per-node queries make + that N round trips for a graph somebody else's project owns.""" + graph = FakeLinks({"b": ["c", "d"], "c": ["e"], "d": ["e"]}) + run(assert_no_block_cycle(graph, "a", "b")) + # Three levels (b → {c,d} → {e} → {}), so at most three queries. + assert graph.queries <= 3 + + +# ── Blocked-ness ──────────────────────────────────────────────────────────── + +def blocker(category: str) -> dict: + return {"id": "x", "title": "t", "category": category} + + +def test_a_finished_blocker_no_longer_blocks(): + """A task that stays red after its dependency shipped is a task people + learn to ignore.""" + assert blocked_by_open([blocker("done")]) == [] + assert blocked_by_open([blocker("cancelled")]) == [] + + +def test_an_open_blocker_blocks(): + for category in ("backlog", "todo", "in_progress"): + assert len(blocked_by_open([blocker(category)])) == 1 + + +def test_the_closing_categories_are_the_ones_the_rest_of_the_app_uses(): + """"Blocked" is derived, and it must mean the same thing here as everywhere + else — the status transition, the overdue filter and this all key off one + definition of finished.""" + assert set(CLOSING_CATEGORIES) == {"done", "cancelled"} + + +def test_a_blocker_with_no_category_is_treated_as_open(): + """Fail towards showing the dependency. Silently dropping one because a row + came back thin is how a blocked task looks ready to start.""" + assert len(blocked_by_open([{"id": "x"}])) == 1 + + +def test_nothing_blocking_is_not_blocked(): + assert blocked_by_open([]) == [] + + +# ── Subtask progress ──────────────────────────────────────────────────────── + +def child(category: str) -> dict: + return {"id": "c", "title": "t", "category": category} + + +def test_progress_counts_done_over_total(): + got = subtask_progress([child("done"), child("todo"), child("todo")]) + assert got == {"done": 1, "total": 3} + + +def test_a_cancelled_subtask_counts_as_resolved(): + """Nothing was completed, but nobody is waiting on it either — and "2 of 3" + beside a list where the third was cancelled reads as work outstanding.""" + assert subtask_progress([child("cancelled"), child("done")]) == { + "done": 2, "total": 2, + } + + +def test_progress_reads_the_CATEGORY_not_a_status_name(): + """A project can name its finished lane "Shipped", "Live" or "Signed off". + Matching on the name would work for exactly the seeded projects.""" + assert subtask_progress([{"category": "done", "status_name": "Shipped"}]) == { + "done": 1, "total": 1, + } + assert subtask_progress([{"category": "todo", "status_name": "Done-ish"}]) == { + "done": 0, "total": 1, + } + + +def test_no_subtasks_is_zero_of_zero_rather_than_a_crash(): + """The panel divides by this to draw a bar.""" + assert subtask_progress([]) == {"done": 0, "total": 0} + + +# ── Wiring ────────────────────────────────────────────────────────────────── + +def test_relations_is_mounted(): + from gateway.routes.projects import router + + paths = {r.path for r in router.routes} + assert "/projects/tasks/{task_id}/relations" in paths + + +def test_creating_a_link_goes_through_the_cycle_guard(): + """The guard is only worth having if the write path calls it. Asserted + against the source because a behavioural test would need a real graph in a + fake that would then be agreeing with itself.""" + from pathlib import Path + + import gateway.routes.projects.tasks as tasks_mod + + source = Path(tasks_mod.__file__).read_text(encoding="utf-8") + body = source.split("async def create_link", 1)[1].split("\n@router", 1)[0] + assert "assert_no_block_cycle" in body, ( + "create_link no longer checks for a dependency loop" + ) + assert "DIRECTED_TYPES" in body, ( + "the guard should apply to directed links only, not to relates_to" + ) diff --git a/workbench/control_plane/src/app/projects/components/RelationsBlock.tsx b/workbench/control_plane/src/app/projects/components/RelationsBlock.tsx new file mode 100644 index 00000000..3da391e6 --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/RelationsBlock.tsx @@ -0,0 +1,251 @@ +"use client"; + +/** + * Projects · subtasks and dependencies in the task panel (WS-27p). + * + * Both existed in the schema since WS-27a and neither had a surface: links + * could be created and deleted but never listed, and subtasks could be created + * but never shown. *"Data with no surface is a promise the product does not + * keep."* + * + * **Blocked by comes first**, because it is the only section that changes what + * somebody should do next; the rest is context. A blocker that has finished + * disappears from it — the gateway derives that — so the section going quiet is + * how you learn you can start. + */ + +import Icon from "@/components/Icon"; +import Badge from "@/components/ui/Badge"; +import Button from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { useCallback, useEffect, useState } from "react"; + +import { projectsApi } from "../lib/api"; +import { + type LinkType, + type Relations, + isResolved, + populated, + progressLabel, + progressPercent, +} from "../lib/relations"; + +const SELECT = + "cc-control rounded-lg border border-border bg-background px-2 py-1.5 " + + "text-xs text-foreground outline-none focus:border-primary/50"; + +const LINK_LABELS: Array<[LinkType, string]> = [ + ["blocks", "blocks"], + ["relates_to", "relates to"], + ["duplicates", "duplicates"], +]; + +interface Props { + taskId: string; + /** Bumped by the panel when it adds a subtask, so this reloads. */ + refreshKey?: number; + onOpenTask: (taskId: string) => void; +} + +export function RelationsBlock({ taskId, refreshKey = 0, onOpenTask }: Props) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [linking, setLinking] = useState(false); + const [target, setTarget] = useState(""); + const [kind, setKind] = useState("blocks"); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + setData(await projectsApi.relations(taskId)); + } catch { + // A panel that works without its relations block beats one that refuses + // to open because the block did not load. + setData(null); + } + }, [taskId]); + + useEffect(() => { + void load(); + }, [load, refreshKey]); + + if (!data) return null; + + const sections = populated(data.links); + const hasAnything = data.subtasks.length > 0 || sections.length > 0; + + async function addLink(event: React.FormEvent) { + event.preventDefault(); + const id = target.trim(); + if (!id) return; + setBusy(true); + setError(null); + try { + await projectsApi.createLink(taskId, id, kind); + setTarget(""); + setLinking(false); + await load(); + } catch (err) { + // The gateway refuses a loop with an explanation; showing it verbatim is + // better than paraphrasing a rule the server owns. + setError(String((err as Error).message)); + } finally { + setBusy(false); + } + } + + async function removeLink(linkId: string) { + setError(null); + try { + await projectsApi.deleteLink(taskId, linkId); + await load(); + } catch (err) { + setError(String((err as Error).message)); + } + } + + return ( +
+ {error ? ( +

+ {error} +

+ ) : null} + + {data.blocked_by.length ? ( + + Blocked by {data.blocked_by.length} + + ) : null} + + {data.subtasks.length ? ( +
+
+ Subtasks + + {progressLabel(data.progress)} + +
+
+
+
+
    + {data.subtasks.map((child) => ( +
  • + +
  • + ))} +
+
+ ) : null} + + {sections.map((s) => ( +
+ {s.label} +
    + {s.links.map((l) => ( +
  • + +
  • + ))} +
+
+ ))} + + {linking ? ( +
+ This + + setTarget(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setLinking(false); + }} + /> + + +
+ ) : ( + + )} +
+ ); +} diff --git a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx index 3d137248..c7f3d24d 100644 --- a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx @@ -23,6 +23,7 @@ import { import { CustomFieldValues } from "./CustomFieldValues"; import { TagPicker } from "./TagPicker"; import { RepeatEditor } from "./RepeatEditor"; +import { RelationsBlock } from "./RelationsBlock"; import { changeLabel } from "../lib/customFields"; import { assigneeLabel, @@ -53,6 +54,12 @@ interface Props { fields?: FieldRow[]; /** WS-27m — the project's registered tags, for the picker's suggestions. */ tags?: TagRow[]; + /** + * WS-27p — open another task by id, for a subtask or a linked task. The page + * owns it because opening one has to resolve ITS project's statuses, which is + * a decision the panel does not have the tree to make. + */ + onOpenTask?: (taskId: string) => void; } function describe(activity: ActivityRow, defs: FieldRow[] = []): string { @@ -97,6 +104,7 @@ export function TaskPanel({ onTaskAdded, fields = [], tags = [], + onOpenTask, }: Props) { const [timeline, setTimeline] = useState([]); const [comment, setComment] = useState(""); @@ -104,6 +112,9 @@ export function TaskPanel({ const commentBox = useRef(null); const [assignee, setAssignee] = useState(""); const [subtask, setSubtask] = useState(""); + // Bumped when this panel adds a subtask, so the relations block re-reads + // rather than showing a list that is one item short. + const [relationsKey, setRelationsKey] = useState(0); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [files, setFiles] = useState([]); @@ -237,6 +248,7 @@ export function TaskPanel({ setSubtask(""); await reload(); onTaskAdded?.(); + setRelationsKey((k) => k + 1); } catch (err) { setError(String((err as Error).message)); } finally { @@ -389,6 +401,16 @@ export function TaskPanel({ })(); }} /> + {/* Both halves existed in the schema since WS-27a with no surface: + links could be created and deleted but never listed, and subtasks + could be created but never shown. */} + {onOpenTask ? ( + + ) : null}
diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts index 9632dbb7..d2bce9eb 100644 --- a/workbench/control_plane/src/app/projects/lib/api.ts +++ b/workbench/control_plane/src/app/projects/lib/api.ts @@ -203,6 +203,27 @@ export const projectsApi = { body: JSON.stringify({ body }), }), + /** + * WS-27p — subtasks and links in both directions, plus derived blocked-ness. + * + * ONE call rather than three: the panel needs all of it at once, and three + * round trips to fill one block is three chances to paint a half-drawn + * dependency section. + */ + relations: (taskId: string) => + call(`tasks/${taskId}/relations`), + + createLink: (taskId: string, targetTaskId: string, linkType: string) => + call<{ id: string }>(`tasks/${taskId}/links`, { + method: "POST", + body: JSON.stringify({ target_task_id: targetTaskId, link_type: linkType }), + }), + + deleteLink: (taskId: string, linkId: string) => + call<{ deleted: string }>(`tasks/${taskId}/links/${linkId}`, { + method: "DELETE", + }), + /** WS-27o — this task's repeat rule, or `{rule: null}`. */ recurrence: (taskId: string) => call<{ rule: RecurrenceRule | null }>(`tasks/${taskId}/recurrence`), diff --git a/workbench/control_plane/src/app/projects/lib/relations.test.ts b/workbench/control_plane/src/app/projects/lib/relations.test.ts new file mode 100644 index 00000000..57df1c28 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/relations.test.ts @@ -0,0 +1,162 @@ +/** + * Projects · dependencies and subtasks in the browser (WS-27p). + * + * One table carries three relationships, and each means something different + * depending on which end you stand at. Showing them under one heading would + * tell people the opposite of the truth half the time, so which link lands in + * which section is what these assert. + */ + +import { describe, expect, it } from "vitest"; + +import { + CLOSED, + type Direction, + type LinkType, + type RelatedTask, + type Relations, + cardSummary, + isResolved, + populated, + progressLabel, + progressPercent, + section, +} from "./relations"; + +const link = ( + type: LinkType, + direction: Direction, + title = "other" +): RelatedTask => ({ + id: `t-${title}`, + link_id: `l-${title}-${direction}`, + link_type: type, + direction, + title, +}); + +const relations = (over: Partial = {}): Relations => ({ + subtasks: [], + progress: { done: 0, total: 0 }, + links: [], + blocked_by: [], + ...over, +}); + +describe("section", () => { + const links = [ + link("blocks", "outgoing", "holds-up"), + link("blocks", "incoming", "waiting-on"), + link("relates_to", "outgoing", "related"), + ]; + + it("keeps the two directions of blocks apart", () => { + // Outgoing is "this holds those up"; incoming is "this is waiting". One + // heading for both tells people the opposite of the truth half the time. + expect(section(links, "blocks", "outgoing").map((l) => l.title)).toEqual([ + "holds-up", + ]); + expect(section(links, "blocks", "incoming").map((l) => l.title)).toEqual([ + "waiting-on", + ]); + }); + + it("does not mix link types", () => { + expect(section(links, "relates_to", "outgoing")).toHaveLength(1); + expect(section(links, "duplicates", "outgoing")).toEqual([]); + }); +}); + +describe("populated", () => { + it("drops empty sections rather than heading them", () => { + // Six empty headings on every task is how a panel becomes something people + // scroll past. + const got = populated([link("blocks", "incoming")]); + expect(got).toHaveLength(1); + expect(got[0].label).toBe("Blocked by"); + }); + + it("puts Blocked by FIRST, because it is the only one that changes what to do next", () => { + const got = populated([ + link("relates_to", "outgoing", "a"), + link("blocks", "incoming", "b"), + ]); + expect(got.map((s) => s.label)).toEqual(["Blocked by", "Related"]); + }); + + it("labels the two directions of duplicates differently", () => { + const got = populated([ + link("duplicates", "outgoing", "a"), + link("duplicates", "incoming", "b"), + ]); + expect(got.map((s) => s.label)).toEqual(["Duplicates", "Duplicated by"]); + }); + + it("is empty for a task with no links at all", () => { + expect(populated([])).toEqual([]); + }); +}); + +describe("progress", () => { + it("reads as a count, not a percentage", () => { + // 33% is a worse answer than "1 of 3" to the question people are asking. + expect(progressLabel({ done: 1, total: 3 })).toBe("1 of 3"); + }); + + it("gives a bar a number rather than NaN when there is nothing", () => { + expect(progressPercent({ done: 0, total: 0 })).toBe(0); + }); + + it("rounds to a whole percent", () => { + expect(progressPercent({ done: 1, total: 3 })).toBe(33); + expect(progressPercent({ done: 3, total: 3 })).toBe(100); + }); +}); + +describe("isResolved", () => { + it("counts cancelled as resolved, like the gateway does", () => { + expect(isResolved("done")).toBe(true); + expect(isResolved("cancelled")).toBe(true); + }); + + it("treats an open or missing category as unresolved", () => { + expect(isResolved("in_progress")).toBe(false); + expect(isResolved(null)).toBe(false); + expect(isResolved(undefined)).toBe(false); + }); + + it("mirrors the gateway's closing categories", () => { + expect([...CLOSED].sort()).toEqual(["cancelled", "done"]); + }); +}); + +describe("cardSummary", () => { + it("says nothing when there is nothing to say", () => { + // A card with no relations must not grow an extra row. + expect(cardSummary(relations())).toBeNull(); + expect(cardSummary(undefined)).toBeNull(); + }); + + it("shows subtask progress when there are subtasks", () => { + expect(cardSummary(relations({ progress: { done: 1, total: 4 } }))).toBe("1 of 4"); + }); + + it("puts BLOCKED ahead of progress", () => { + // A task with subtasks and an unfinished blocker cannot be started, and + // that is the more urgent fact. + const got = cardSummary( + relations({ + progress: { done: 1, total: 4 }, + blocked_by: [link("blocks", "incoming")], + }) + ); + expect(got).toBe("Blocked by 1"); + }); + + it("says nothing for a task whose blockers have all finished", () => { + // The gateway already filtered them out; this is the consequence — a card + // that stayed marked blocked after its dependency shipped is a card people + // learn to ignore. + expect(cardSummary(relations({ blocked_by: [] }))).toBeNull(); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/relations.ts b/workbench/control_plane/src/app/projects/lib/relations.ts new file mode 100644 index 00000000..a84806d6 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/relations.ts @@ -0,0 +1,129 @@ +/** + * Projects · dependencies and subtasks in the browser (WS-27p). + * + * The gateway derives what is blocked; this decides how it reads. The awkward + * part is that one table carries three relationships and each one means + * something different depending on which end you are standing at — "blocks" + * outgoing is *"this holds those up"*, incoming is *"this is waiting"*, and a + * client that showed them under one heading would be telling people the + * opposite of the truth half the time. + */ + +export type LinkType = "blocks" | "relates_to" | "duplicates"; +export type Direction = "outgoing" | "incoming"; + +export interface RelatedTask { + id: string; + link_id: string; + link_type: LinkType; + direction: Direction; + title: string; + task_number?: number | null; + status_name?: string | null; + category?: string | null; + completed_at?: string | null; +} + +export interface SubtaskRow { + id: string; + title: string; + task_number?: number | null; + status_id: string; + status_name?: string | null; + category?: string | null; + completed_at?: string | null; +} + +export interface Relations { + subtasks: SubtaskRow[]; + progress: { done: number; total: number }; + links: RelatedTask[]; + blocked_by: RelatedTask[]; +} + +/** Mirrors the gateway's `CLOSING_CATEGORIES`. */ +export const CLOSED = ["done", "cancelled"]; + +export const isResolved = (category: string | null | undefined): boolean => + CLOSED.includes(category ?? ""); + +/** + * The headings a relations block shows, in the order it shows them. + * + * "Blocked by" comes FIRST because it is the only one that changes what + * somebody should do next. The others are context. + */ +export const SECTIONS: Array<{ + key: string; + label: string; + type: LinkType; + direction: Direction; +}> = [ + { key: "blocked_by", label: "Blocked by", type: "blocks", direction: "incoming" }, + { key: "blocks", label: "Blocks", type: "blocks", direction: "outgoing" }, + { key: "relates", label: "Related", type: "relates_to", direction: "outgoing" }, + { key: "relates_in", label: "Related", type: "relates_to", direction: "incoming" }, + { key: "dupes", label: "Duplicates", type: "duplicates", direction: "outgoing" }, + { key: "dupes_in", label: "Duplicated by", type: "duplicates", direction: "incoming" }, +]; + +/** The links belonging to one section. */ +export function section( + links: RelatedTask[], + type: LinkType, + direction: Direction +): RelatedTask[] { + return links.filter((l) => l.link_type === type && l.direction === direction); +} + +/** + * Sections that have something in them, with their links. + * + * Empty ones are dropped rather than rendered as headings with nothing under — + * six empty headings on every task is how a panel becomes something people + * scroll past. + */ +export function populated(links: RelatedTask[]): Array<{ + key: string; + label: string; + links: RelatedTask[]; +}> { + return SECTIONS.map(({ key, label, type, direction }) => ({ + key, + label, + links: section(links, type, direction), + })).filter((s) => s.links.length > 0); +} + +/** + * How the subtask progress reads. + * + * "0 of 3" rather than "0%": a percentage of three things is a precision + * nobody asked for, and 33% is a worse answer than "1 of 3" to the question + * people are actually asking. + */ +export function progressLabel(progress: { done: number; total: number }): string { + return `${progress.done} of ${progress.total}`; +} + +/** 0–100 for a bar. `0` when there is nothing, rather than NaN. */ +export function progressPercent(progress: { done: number; total: number }): number { + if (progress.total <= 0) return 0; + return Math.round((progress.done / progress.total) * 100); +} + +/** + * The one-line summary a card shows. + * + * `null` when there is nothing worth saying, so a card that has no relations + * grows no extra row. Blocked wins over progress: a task with two subtasks and + * an unfinished blocker cannot be started, and that is the more urgent fact. + */ +export function cardSummary(relations: Relations | undefined): string | null { + if (!relations) return null; + if (relations.blocked_by.length) { + return `Blocked by ${relations.blocked_by.length}`; + } + if (relations.progress.total > 0) return progressLabel(relations.progress); + return null; +} diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx index aa2eb08f..44745a80 100644 --- a/workbench/control_plane/src/app/projects/page.tsx +++ b/workbench/control_plane/src/app/projects/page.tsx @@ -731,6 +731,9 @@ function ProjectsWorkspace() { statuses={panelStatuses} fields={fields} tags={tags} + // WS-27p — opening a subtask or a linked task resolves ITS project's + // statuses, which the panel has no tree to do. + onOpenTask={(id) => void openTaskById(id)} onClose={() => setOpenTask(null)} onTaskAdded={() => { if (selected) void loadProject(selected); From 37cedb122984c14ae98a0328ce117463d0984cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:28:52 +0000 Subject: [PATCH 03/22] =?UTF-8?q?feat(WS-27s):=20the=20shared=20task=20car?= =?UTF-8?q?d=20=E2=80=94=20familiar,=20not=20ported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "the UI, kanban, task cards etc can be taken from the tasks app right?" Familiar, yes. Taken, no. /tasks' TaskCard is 395 lines bound to useTaskStore and to GtdItem's own fields — energy, deepWork, disposition — none of which pm_tasks has or should grow, and D-PM-6 retires gtd_items at WS-27h, which would take the Projects board with it. What moves is the VOCABULARY: @/lib/taskCard decides which chips a task earns, what counts as overdue and what an avatar's letters are; @/components/TaskMeta is the one file that turns a tone name into a colour. Neither knows about a store. A card can only show what the list endpoint returns, and it was returning almost nothing. Links and parent_task_id have been readable since WS-27p — one task at a time. A board draws them on every card at once, so most of this is backend: two aggregates over the page's ids fill subtasks {done,total} and blocked_by_count on every row. Per card that is N+1 across an imported workspace, and at three-task scale it looks identical. A finished blocker does not block, and the count says so in SQL rather than after — a card still marked blocked after its dependency shipped is a card people learn to ignore. Archived subtasks leave the denominator for the matching reason: counted, "2/3" could never reach 3/3. A zero earns no chip; chip order is fixed so the row is scanned, not read. Overdue changes the icon as well as the tone, so the signal survives a reader who cannot tell muted from destructive. One behaviour change outside Projects: /tasks' isOverdue checked only the date, painting every completed task with a past due date red forever. Sharing the function fixed that side too. The fake needed teaching, as it did for WS-27n, and the same lesson applied: every clause is mirrored only when the statement carries it, and which end of a `blocks` link is the blocked one is read off the SQL rather than assumed — a mirror that filters unconditionally agrees with itself no matter what the route stops emitting. Verified: 326 backend + 988 frontend tests; ruff and xenon clean; theme conformance green; next build clean; seven mutants killed (two behavioural kills for the SQL clauses, not only structural); one equivalent mutant found and removed along with the test that asserted nothing; live Postgres 16 run against real endpoint functions all green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../specs/project_management_app.md | 49 +++ .../gateway/routes/projects/filters.py | 67 ++++ .../gateway/gateway/routes/projects/tasks.py | 18 +- tests/unit/_projects_fakes.py | 93 ++++++ tests/unit/test_projects_cards.py | 298 ++++++++++++++++++ .../src/app/projects/components/TaskBoard.tsx | 24 +- .../src/app/projects/components/TaskList.tsx | 20 +- .../control_plane/src/app/projects/lib/api.ts | 8 + .../src/app/projects/lib/card.test.ts | 100 ++++++ .../src/app/projects/lib/card.ts | 33 ++ .../control_plane/src/app/tasks/lib/utils.ts | 52 +-- .../control_plane/src/components/TaskMeta.tsx | 98 ++++++ .../control_plane/src/lib/taskCard.test.ts | 284 +++++++++++++++++ workbench/control_plane/src/lib/taskCard.ts | 231 ++++++++++++++ 14 files changed, 1312 insertions(+), 63 deletions(-) create mode 100644 tests/unit/test_projects_cards.py create mode 100644 workbench/control_plane/src/app/projects/lib/card.test.ts create mode 100644 workbench/control_plane/src/app/projects/lib/card.ts create mode 100644 workbench/control_plane/src/components/TaskMeta.tsx create mode 100644 workbench/control_plane/src/lib/taskCard.test.ts create mode 100644 workbench/control_plane/src/lib/taskCard.ts diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index b76f8fac..dad2a2cd 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -949,6 +949,7 @@ interesting it is to build. | 8 | ~~**Dependency and subtask UI**~~ | — | **WS-27p ✅ BUILT 2026-08-07** | | 9 | **Calendar / timeline view** | The third view ClickUp users actually use, after list and board | **WS-27q** | | 10 | **Global task search** | `?q=` exists on the list endpoint; there is no search surface | **WS-27r** | +| 11 | ~~**The card looks nothing like /tasks'**~~ | — | **WS-27s ✅ BUILT 2026-08-07** | **Deliberately NOT on this list:** sprints (a stated non-goal, §1), time tracking and checklists (Paca moved both out of core into plugins — the growth path is subtraction), and @@ -1609,3 +1610,51 @@ task is how a panel becomes something people scroll past. else in this app does: a project can name its finished lane "Shipped" or "Signed off", and `cancelled` counts as resolved even though nothing was completed. It reads as "1 of 3" rather than a percentage — 33% is a worse answer than "1 of 3" to the question people are asking. + +### 11.15 WS-27s — the shared task card (built 2026-08-07) + +Not on the parity backlog, and asked for directly: *"the UI, kanban, task cards etc can be +taken from the tasks app right? so that the experience seems familiar?"* + +**Familiar, yes. Taken, no — and the difference is the whole ticket.** `/tasks`'s `TaskCard` +is 395 lines bound to `useTaskStore` and to `GtdItem`'s own fields — `energy`, `deepWork`, +`disposition`, `nextAction` — none of which `pm_tasks` has or should grow. Worse, D-PM-6 has +`gtd_items` retiring at WS-27h, so a straight port would take the Projects board down with +it. What moved instead is the **vocabulary**: `@/lib/taskCard` holds how a duration reads, +what an avatar's letters are, what counts as overdue, and which chips a task earns; +`@/components/TaskMeta` is the one file that turns a tone name into a colour. Both apps draw +from those, and neither knows about the other's store. + +**A card can only show what the LIST endpoint returns, and it was returning almost nothing.** +`pm_task_links` and `parent_task_id` have been readable since WS-27p — *one task at a time*. +A board draws them on every card at once, so this ticket is mostly a backend one: two +aggregates over the page's ids (`attach_relation_counts`), filling `subtasks {done,total}` +and `blocked_by_count` on every row. Per card it would be N+1 across an imported workspace of +hundreds, and at the three-task scale of any test the two look identical. + +**A finished blocker does not block, and the count says so in SQL.** The same rule WS-27p's +`blocked_by_open` makes, moved into the aggregate rather than applied after: a card still +marked blocked after its dependency shipped is a card people learn to ignore, and one round +trip per card to find out is the N+1 again. Archived subtasks leave the denominator for the +matching reason — counted, "2/3" could never reach 3/3. + +**A zero earns no chip.** Most tasks have no subtasks, no tags and no blockers; drawing "0" +for each turns the meta row into noise and pushes the chips that mean something off the edge +of a 288px column. Chip order is fixed — blocked, due, progress, then the quiet counts — so +the row can be scanned rather than read. + +**Overdue is past due AND still open**, and it changes the *icon* as well as the tone, so the +signal survives a reader who cannot tell muted from destructive. `/tasks` was checking only +the date, which painted every completed task with a past due date red forever; sharing the +function fixed that side too, and it is the one behaviour change this ticket makes outside +Projects. + +**What the card honestly does not claim.** No attachment count and no estimate: attachments +are counted on the single-task read (WS-27i) and there is no estimate column at all. A +plausible zero would be the card asserting something the endpoint never told it. + +The hermetic fake needed teaching, as it did for WS-27n — and the lesson recorded there +applied again: every clause in the two roll-ups is mirrored **only when the statement carries +it**, and which end of a `blocks` link is the blocked one is read off the SQL rather than +assumed. A mirror that filters unconditionally agrees with itself no matter what the route +stops emitting, which is how a deleted WHERE clause survives a green suite. diff --git a/apps/services/gateway/gateway/routes/projects/filters.py b/apps/services/gateway/gateway/routes/projects/filters.py index e7ea244d..29c45e19 100644 --- a/apps/services/gateway/gateway/routes/projects/filters.py +++ b/apps/services/gateway/gateway/routes/projects/filters.py @@ -265,6 +265,73 @@ def normalise_view_config(config: Any) -> dict[str, Any]: """ +#: Subtask progress and open-blocker counts for a page of tasks, in TWO queries. +#: +#: The same trade `_ASSIGNEES_SQL` makes and for the same reason: a board draws +#: these badges on every card, and asking per card is N+1 across an imported +#: workspace of hundreds. Aggregated over the page's ids rather than joined onto +#: the list itself, because a join would repeat the task row per child and break +#: `LIMIT`. +_SUBTASK_COUNTS_SQL = """ +SELECT t.parent_task_id AS parent, + count(*) AS total, + count(*) FILTER (WHERE s.category = ANY(:closed)) AS done + FROM pm_tasks t + JOIN pm_task_statuses s ON s.id = t.status_id + WHERE t.parent_task_id = ANY(CAST(:ids AS uuid[])) + AND t.archived_at IS NULL + GROUP BY t.parent_task_id +""" + +#: How many still-OPEN tasks block each of these. +#: +#: Filtered to open blockers in SQL rather than counted and filtered after: a +#: finished blocker blocks nothing (WS-27p), and a card that stays marked +#: blocked after its dependency shipped is a card people learn to ignore. +_BLOCKER_COUNTS_SQL = """ +SELECT l.target_task_id AS blocked, count(*) AS blockers + FROM pm_task_links l + JOIN pm_tasks t ON t.id = l.source_task_id + JOIN pm_task_statuses s ON s.id = t.status_id + WHERE l.link_type = 'blocks' + AND l.target_task_id = ANY(CAST(:ids AS uuid[])) + AND NOT (s.category = ANY(:closed)) + GROUP BY l.target_task_id +""" + + +async def attach_relation_counts( + db: Any, rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Fill each row's ``subtasks`` and ``blocked_by_count``, mutating in place. + + Every row gets both keys, including the ones with neither — a missing key + and a zero read the same to a careless client, and "has no subtasks" is a + state the card draws nothing for rather than an absence it guesses at. + """ + for row in rows: + row["subtasks"] = {"done": 0, "total": 0} + row["blocked_by_count"] = 0 + ids = [str(r["id"]) for r in rows if r.get("id")] + if not ids: + return rows + + args = {"ids": ids, "closed": list(CLOSED_CATEGORIES)} + counts = (await db.execute(text(_SUBTASK_COUNTS_SQL), args)).fetchall() + by_parent = { + str(r.parent): {"done": int(r.done or 0), "total": int(r.total or 0)} + for r in counts + } + blocked = (await db.execute(text(_BLOCKER_COUNTS_SQL), args)).fetchall() + by_blocked = {str(r.blocked): int(r.blockers or 0) for r in blocked} + + for row in rows: + key = str(row["id"]) + row["subtasks"] = by_parent.get(key, {"done": 0, "total": 0}) + row["blocked_by_count"] = by_blocked.get(key, 0) + return rows + + async def attach_assignees(db: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: """Fill each row's ``assignees``, mutating and returning the list. diff --git a/apps/services/gateway/gateway/routes/projects/tasks.py b/apps/services/gateway/gateway/routes/projects/tasks.py index e2a9ae86..00497d72 100644 --- a/apps/services/gateway/gateway/routes/projects/tasks.py +++ b/apps/services/gateway/gateway/routes/projects/tasks.py @@ -59,6 +59,7 @@ from gateway.routes.projects.custom_fields import apply_values, load_definitions from gateway.routes.projects.filters import ( attach_assignees, + attach_relation_counts, build_task_filters, ) from gateway.routes.projects.notifications import notify @@ -200,15 +201,14 @@ async def list_tasks( ), {**params, "limit": page.limit, "offset": page.offset}, )).fetchall() - # Assignees on the LIST, not only on the single-task read. Without - # them the board cannot draw an owner or group by one, and fetching - # them per card is N+1 across an imported workspace of hundreds. - return ListResponse( - rows=await attach_assignees( - db, [row_to_dict(r, TaskModel) for r in rows], - ), - total=int(total), - ) + # Assignees, subtask progress and blocked-ness on the LIST, not only on + # the single-task read. Without them a card cannot draw an owner, a + # progress count or a blocked flag — and fetching any of the three per + # card is N+1 across an imported workspace of hundreds. + page_rows = [row_to_dict(r, TaskModel) for r in rows] + await attach_assignees(db, page_rows) + await attach_relation_counts(db, page_rows) + return ListResponse(rows=page_rows, total=int(total)) finally: await db.close() diff --git a/tests/unit/_projects_fakes.py b/tests/unit/_projects_fakes.py index a4a562c0..5749ef18 100644 --- a/tests/unit/_projects_fakes.py +++ b/tests/unit/_projects_fakes.py @@ -361,6 +361,17 @@ async def execute(self, sql: Any, params: dict | None = None) -> _Result: SimpleNamespace(task_id=task_id, people=sorted(people)) for task_id, people in grouped.items() ]) + # The two card-badge roll-ups, taught for the same reason as the + # assignee one above: `GROUP BY` is not a shape the generic WHERE + # reader can parse. Both fingerprints name a statement-specific ALIAS + # rather than a table, because `pm_tasks` and `pm_task_links` each + # appear in several statements and a fingerprint that is merely + # *present* in the target is how the WS-27n audience-clause collision + # happened. + if "AS parent," in statement and "GROUP BY" in statement: + return _Result(self._subtask_counts(statement, args)) + if "AS blocked," in statement and "GROUP BY" in statement: + return _Result(self._blocker_counts(statement, args)) head = statement.split(None, 1)[0].upper() table = self._table(statement) if head == "INSERT": @@ -377,6 +388,88 @@ def _table(self, sql: str) -> str: raise AssertionError(f"fake could not find a table in: {sql}") return match.group(1) + # page roll-ups ------------------------------------------------------ + def _categories(self) -> dict[str, str]: + return { + str(s["id"]): str(s.get("category") or "") + for s in self.rows("pm_task_statuses") + } + + def _subtask_counts(self, statement: str, args: dict) -> list[Any]: + """``{parent, total, done}`` per parent, over the page's ids. + + Every clause is applied ONLY when the statement carries it, the + ``_select`` convention: a mirror that filters unconditionally agrees + with itself no matter what the route stops emitting, which is how a + deleted WHERE clause survives a green suite. + """ + wanted = {str(i) for i in (args.get("ids") or [])} + closed = set(args.get("closed") or []) + skips_archived = "t.archived_at IS NULL" in statement + counts_closed = "FILTER (WHERE s.category = ANY(:closed))" in statement + categories = self._categories() + grouped: dict[str, list[str]] = {} + for task in self.rows("pm_tasks"): + parent = str(task.get("parent_task_id") or "") + if parent not in wanted: + continue + if skips_archived and task.get("archived_at") is not None: + continue + grouped.setdefault(parent, []).append( + categories.get(str(task.get("status_id")), "") + ) + return [ + SimpleNamespace( + parent=parent, + total=len(found), + done=( + sum(1 for c in found if c in closed) + if counts_closed else len(found) + ), + ) + for parent, found in grouped.items() + ] + + def _blocker_counts(self, statement: str, args: dict) -> list[Any]: + """``{blocked, blockers}`` counting only blockers that are still OPEN. + + Which end of the link is the blocked one is read off the statement + rather than assumed, so reversing the SQL's direction reverses this + mirror's answer instead of being invisible to it. + """ + wanted = {str(i) for i in (args.get("ids") or [])} + closed = set(args.get("closed") or []) + blocked_col = ( + "target_task_id" if "l.target_task_id AS blocked" in statement + else "source_task_id" + ) + blocker_col = ( + "source_task_id" if "t.id = l.source_task_id" in statement + else "target_task_id" + ) + only_blocks = "l.link_type = 'blocks'" in statement + skips_closed = "NOT (s.category = ANY(:closed))" in statement + categories = self._categories() + tasks = {str(t["id"]): t for t in self.rows("pm_tasks")} + counted: dict[str, int] = {} + for link in self.rows("pm_task_links"): + blocked = str(link.get(blocked_col) or "") + if blocked not in wanted: + continue + if only_blocks and link.get("link_type") != "blocks": + continue + blocker = tasks.get(str(link.get(blocker_col) or "")) + if blocker is None: + continue + category = categories.get(str(blocker.get("status_id")), "") + if skips_closed and category in closed: + continue + counted[blocked] = counted.get(blocked, 0) + 1 + return [ + SimpleNamespace(blocked=blocked, blockers=count) + for blocked, count in counted.items() + ] + # verbs -------------------------------------------------------------- def _insert(self, statement: str, table: str, args: dict) -> _Result: # The task counter is a read-modify-write in one statement; modelling it diff --git a/tests/unit/test_projects_cards.py b/tests/unit/test_projects_cards.py new file mode 100644 index 00000000..5f6e7303 --- /dev/null +++ b/tests/unit/test_projects_cards.py @@ -0,0 +1,298 @@ +"""WS-27s — the badges a card needs, on the LIST endpoint. + +Spec: ``ai-company-brain/specs/project_management_app.md`` §11.15. + +A Projects card should read like a Tasks card. Most of what makes those cards +legible is data Projects already stores and never sends to the board: how many +subtasks are finished, and whether anything is holding this task up. WS-27p made +both readable **one task at a time**; a board draws them on every card at once. + +The claims worth pinning are the ones where a plausible implementation is wrong: + +* **it is two aggregates, not two-per-card.** N+1 across an imported workspace + is the difference between a board and a spinner, and it is invisible in any + test that seeds three tasks. +* **a finished blocker does not block.** A card still marked blocked after its + dependency shipped is a card people learn to ignore — the same argument + WS-27p's ``blocked_by_open`` makes, applied to the count. +* **an archived subtask is not counted.** It would sit permanently in the + denominator, so "2/5" would never reach 5/5 and the badge would be a lie. +* **every row carries both keys.** A missing key and a zero read the same to a + careless client; "no subtasks" is a thing the card draws nothing for, not an + absence it has to guess at. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from gateway.routes.projects import activities as pm_activities +from gateway.routes.projects import admin as pm_admin +from gateway.routes.projects import core as pm_core +from gateway.routes.projects import me as pm_me +from gateway.routes.projects import tasks as pm_tasks +from gateway.routes.projects import tree as pm_tree +from gateway.routes.projects import views as pm_views +from gateway.routes.projects.filters import attach_relation_counts + +from tests.unit._projects_fakes import ( + FakeProjectsDB, + bind_db, + page, + projects_user, + silence_events, +) + +MODULES = (pm_core, pm_tree, pm_tasks, pm_activities, pm_admin, pm_views, pm_me) +USER = projects_user() + +FILTERS = Path("apps/services/gateway/gateway/routes/projects/filters.py") + + +@pytest.fixture +def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB: + fake = FakeProjectsDB() + bind_db(monkeypatch, fake, MODULES) + return fake + + +@pytest.fixture +def events(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, dict]]: + return silence_events(monkeypatch, MODULES) + + +def _workspace(db: FakeProjectsDB) -> tuple: + project = db.seed_project(name="Ops", subject="owner@fracktal.in") + todo = db.seed_status(project.id, name="To do", category="todo", is_default=True) + done = db.seed_status( + project.id, name="Done", category="done", is_default=False, position=40, + ) + return project, todo, done + + +def _rows(result) -> dict[str, dict]: + return {str(row["id"]): row for row in result.rows} + + +# ── Subtask progress ──────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_card_reports_how_many_of_its_subtasks_are_finished(db, events): + project, todo, done = _workspace(db) + parent = db.seed_task(project.id, todo.id, title="Ship it") + db.seed_task(project.id, done.id, title="One", parent_task_id=parent.id) + db.seed_task(project.id, done.id, title="Two", parent_task_id=parent.id) + db.seed_task(project.id, todo.id, title="Three", parent_task_id=parent.id) + + result = await pm_tasks.list_tasks(user=USER, page=page()) + + assert _rows(result)[str(parent.id)]["subtasks"] == {"done": 2, "total": 3} + + +@pytest.mark.asyncio +async def test_progress_is_counted_from_the_status_category_not_completed_at( + db, events, +): + """A project may name its finished lane anything, and `cancelled` counts as + resolved even though nothing was completed — the reason every other derived + word in this app keys off the category.""" + project, todo, _done = _workspace(db) + dropped = db.seed_status( + project.id, name="Won't do", category="cancelled", is_default=False, + position=50, + ) + parent = db.seed_task(project.id, todo.id, title="Ship it") + db.seed_task( + project.id, dropped.id, title="Abandoned", parent_task_id=parent.id, + completed_at=None, + ) + + result = await pm_tasks.list_tasks(user=USER, page=page()) + + assert _rows(result)[str(parent.id)]["subtasks"] == {"done": 1, "total": 1} + + +@pytest.mark.asyncio +async def test_an_archived_subtask_leaves_the_denominator(db, events): + """⚠️ Counted, it would sit in the denominator forever: "2/3" could never + become 3/3 and the badge would be permanently wrong.""" + project, todo, done = _workspace(db) + parent = db.seed_task(project.id, todo.id, title="Ship it") + db.seed_task(project.id, done.id, title="One", parent_task_id=parent.id) + db.seed_task( + project.id, todo.id, title="Dropped", parent_task_id=parent.id, + archived_at="2026-08-01T00:00:00Z", + ) + + result = await pm_tasks.list_tasks(user=USER, page=page()) + + assert _rows(result)[str(parent.id)]["subtasks"] == {"done": 1, "total": 1} + + +@pytest.mark.asyncio +async def test_a_task_with_no_subtasks_still_carries_the_key(db, events): + project, todo, _done = _workspace(db) + lonely = db.seed_task(project.id, todo.id, title="Alone") + + result = await pm_tasks.list_tasks(user=USER, page=page()) + + row = _rows(result)[str(lonely.id)] + assert row["subtasks"] == {"done": 0, "total": 0} + assert row["blocked_by_count"] == 0 + + +# ── Blocked-ness ──────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_card_reports_how_many_open_tasks_block_it(db, events): + project, todo, _done = _workspace(db) + blocked = db.seed_task(project.id, todo.id, title="Waiting") + for title in ("First", "Second"): + blocker = db.seed_task(project.id, todo.id, title=title) + db.seed( + "pm_task_links", source_task_id=blocker.id, target_task_id=blocked.id, + link_type="blocks", created_by="owner@fracktal.in", + ) + + result = await pm_tasks.list_tasks(user=USER, page=page()) + + assert _rows(result)[str(blocked.id)]["blocked_by_count"] == 2 + + +@pytest.mark.asyncio +async def test_a_finished_blocker_stops_blocking(db, events): + """⚠️ The WS-27p rule, applied to the count: a blocker that is done or + cancelled holds nothing up, and a card that stays red after its dependency + shipped teaches people to ignore the badge.""" + project, todo, done = _workspace(db) + blocked = db.seed_task(project.id, todo.id, title="Waiting") + shipped = db.seed_task(project.id, done.id, title="Shipped") + still_open = db.seed_task(project.id, todo.id, title="Open") + for blocker in (shipped, still_open): + db.seed( + "pm_task_links", source_task_id=blocker.id, target_task_id=blocked.id, + link_type="blocks", created_by="owner@fracktal.in", + ) + + result = await pm_tasks.list_tasks(user=USER, page=page()) + + assert _rows(result)[str(blocked.id)]["blocked_by_count"] == 1 + + +@pytest.mark.asyncio +async def test_only_blocks_counts_a_related_task_is_not_a_blocker(db, events): + project, todo, _done = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Waiting") + other = db.seed_task(project.id, todo.id, title="Related") + for link_type in ("relates_to", "duplicates"): + db.seed( + "pm_task_links", source_task_id=other.id, target_task_id=task.id, + link_type=link_type, created_by="owner@fracktal.in", + ) + + result = await pm_tasks.list_tasks(user=USER, page=page()) + + assert _rows(result)[str(task.id)]["blocked_by_count"] == 0 + + +@pytest.mark.asyncio +async def test_blocking_something_else_does_not_make_this_task_blocked(db, events): + """⚠️ The link is directed, and reading it the wrong way round marks every + upstream task blocked by its own downstream work.""" + project, todo, _done = _workspace(db) + upstream = db.seed_task(project.id, todo.id, title="Do first") + downstream = db.seed_task(project.id, todo.id, title="Do second") + db.seed( + "pm_task_links", source_task_id=upstream.id, target_task_id=downstream.id, + link_type="blocks", created_by="owner@fracktal.in", + ) + + rows = _rows(await pm_tasks.list_tasks(user=USER, page=page())) + + assert rows[str(upstream.id)]["blocked_by_count"] == 0 + assert rows[str(downstream.id)]["blocked_by_count"] == 1 + + +# ── Shape ─────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_it_is_two_queries_for_a_whole_page_not_two_per_card(db, events): + """⚠️ The claim a three-task fixture cannot make on its own: N+1 here is the + difference between a board and a spinner on an imported workspace, and it + looks identical to the correct version at this scale.""" + project, todo, _done = _workspace(db) + for n in range(12): + db.seed_task(project.id, todo.id, title=f"Task {n}") + + db.statements.clear() + await pm_tasks.list_tasks(user=USER, page=page()) + + subtask_queries = [s for s in db.statements if "AS parent" in s] + blocker_queries = [s for s in db.statements if "AS blocked" in s] + assert len(subtask_queries) == 1 + assert len(blocker_queries) == 1 + + +@pytest.mark.asyncio +async def test_an_empty_page_asks_the_database_nothing(db, events): + _workspace(db) + + db.statements.clear() + result = await pm_tasks.list_tasks(user=USER, page=page()) + + assert result.rows == [] + assert not [s for s in db.statements if "AS parent" in s or "AS blocked" in s] + + +@pytest.mark.asyncio +async def test_rows_without_an_id_do_not_reach_the_query() -> None: + """Defensive, and cheap: an id of ``None`` cast to a uuid[] is a 500, and + the roll-up is called on whatever the list produced.""" + class Counting: + def __init__(self) -> None: + self.calls = 0 + + async def execute(self, sql, params=None): + self.calls += 1 + raise AssertionError("should not have queried") + + rows: list[dict] = [{"id": None}, {}] + db = Counting() + + assert await attach_relation_counts(db, rows) is rows + assert db.calls == 0 + assert all(r["subtasks"] == {"done": 0, "total": 0} for r in rows) + + +# ── Structural — what the fake cannot decide ──────────────────────────────── + +def test_the_subtask_roll_up_excludes_archived_children_in_SQL() -> None: + """The fake re-implements this clause in Python, so only the statement text + can say whether the route still carries it.""" + source = FILTERS.read_text(encoding="utf-8") + match = re.search(r"_SUBTASK_COUNTS_SQL = \"\"\"(.*?)\"\"\"", source, re.S) + assert match is not None + assert "archived_at IS NULL" in match.group(1) + + +def test_the_blocker_roll_up_filters_closed_blockers_in_SQL() -> None: + """⚠️ Counted in SQL and filtered in Python would be correct and slow — but + filtered *nowhere* looks identical until a blocker is finished.""" + source = FILTERS.read_text(encoding="utf-8") + match = re.search(r"_BLOCKER_COUNTS_SQL = \"\"\"(.*?)\"\"\"", source, re.S) + assert match is not None + body = match.group(1) + assert "NOT (s.category = ANY(:closed))" in body + assert "l.link_type = 'blocks'" in body + + +def test_neither_roll_up_scans_the_whole_table() -> None: + """Both are bounded by the page's ids. Losing that bound is a query that + grows with the workspace and returns rows nobody asked for.""" + source = FILTERS.read_text(encoding="utf-8") + for name in ("_SUBTASK_COUNTS_SQL", "_BLOCKER_COUNTS_SQL"): + match = re.search(rf"{name} = \"\"\"(.*?)\"\"\"", source, re.S) + assert match is not None, name + assert "= ANY(CAST(:ids AS uuid[]))" in match.group(1), name diff --git a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx index 2017b1c9..d35ca7ac 100644 --- a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx @@ -18,10 +18,12 @@ * `planDrop`, which is one row in the normal case and the whole group on the * first drag into an unordered column. */ +import { AvatarStack, TaskMeta } from "@/components/TaskMeta"; import { useMemo, useState } from "react"; import type { TaskRow } from "../lib/api"; import { buildColumnDropUpdate, planDrop, sortForView } from "../lib/board"; +import { cardChips } from "../lib/card"; import { type GroupBy, type TaskGroup, personLabel } from "../lib/grouping"; interface Props { @@ -125,14 +127,20 @@ export function TaskBoard({ selected?.has(task.id) ? "border-primary" : "border-border" }`} > - {task.title} - - {task.task_number ? #{task.task_number} : null} - {task.assignees?.length ? ( - - {task.assignees.map(personLabel).join(", ")} - - ) : null} + + {task.title} + + {/* The chip row and the owner strip are the shared card + vocabulary (WS-27s) — the same components /tasks draws, + so a task looks like the same kind of thing in both. */} + + + {task.task_number ? `#${task.task_number}` : ""} + diff --git a/workbench/control_plane/src/app/projects/components/TaskList.tsx b/workbench/control_plane/src/app/projects/components/TaskList.tsx index 3c61a8bb..d479e5fc 100644 --- a/workbench/control_plane/src/app/projects/components/TaskList.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskList.tsx @@ -13,8 +13,11 @@ * board and list must not change which tasks are on screen or how they are * gathered, which is why both take the output of one `groupTasks` call. */ +import { AvatarStack, TaskMeta } from "@/components/TaskMeta"; + import type { StatusRow, TaskRow } from "../lib/api"; import { sortForView } from "../lib/board"; +import { cardChips } from "../lib/card"; import { type GroupBy, type TaskGroup, personLabel } from "../lib/grouping"; interface Props { @@ -65,7 +68,12 @@ export function TaskList({ Title Status Assignees - Due + {/* Was "Due", showing a bare locale date. The shared chip row + (WS-27s) carries the due date *and* says when it is overdue, + what is blocking, and how far a checklist has got — the same + strip the board card draws, so the two views describe a task + identically. Renamed because it is no longer only the date. */} + Details {groups.map((group) => { @@ -128,12 +136,14 @@ export function TaskList({ {status?.name ?? "—"} - {task.assignees?.length - ? task.assignees.map(personLabel).join(", ") - : "—"} + {task.assignees?.length ? ( + + ) : ( + "—" + )} - {task.due_at ? new Date(task.due_at).toLocaleDateString() : "—"} + ); diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts index d2bce9eb..6f6ca61c 100644 --- a/workbench/control_plane/src/app/projects/lib/api.ts +++ b/workbench/control_plane/src/app/projects/lib/api.ts @@ -43,6 +43,14 @@ export interface TaskRow { * unset rather than that the values have not loaded. */ custom_fields?: Record; + /** + * WS-27s — the two counts a card draws, aggregated for the whole page rather + * than fetched per row. Always present on the list endpoint; optional here + * because the same type describes a row from `getTask`, where the panel reads + * the full relations block instead. + */ + subtasks?: { done: number; total: number }; + blocked_by_count?: number; } export interface StatusRow { diff --git a/workbench/control_plane/src/app/projects/lib/card.test.ts b/workbench/control_plane/src/app/projects/lib/card.test.ts new file mode 100644 index 00000000..899cf7cf --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/card.test.ts @@ -0,0 +1,100 @@ +/** + * WS-27s — the seam between a `TaskRow` and the shared card. + * + * The translation is small, which is exactly why it is worth pinning: the + * failure mode is not a crash, it is a card that quietly draws nothing because + * a snake_case field was read by its camelCase name and came back `undefined`. + * A board full of tasks with no badges looks like a board full of simple tasks. + */ + +import { describe, expect, it } from "vitest"; + +import type { TaskRow } from "./api"; +import { cardChips, taskFacts } from "./card"; + +const NOW = Date.parse("2026-08-07T12:00:00Z"); +const hours = (n: number) => new Date(NOW + n * 3_600_000).toISOString(); + +const row = (over: Partial = {}): TaskRow => ({ + id: "t1", + project_id: "p1", + root_project_id: "p1", + status_id: "s1", + title: "Ship it", + ...over, +}); + +describe("taskFacts", () => { + it("reads every field off the snake_case row", () => { + // ⚠️ The whole point of this test. A camelCase typo here is `undefined`, + // and `undefined` draws no chip — a silent blank, not an error. + expect( + taskFacts( + row({ + due_at: hours(-2), + completed_at: hours(-1), + subtasks: { done: 1, total: 3 }, + blocked_by_count: 2, + tags: ["ops", "urgent"], + }), + ), + ).toEqual({ + dueAt: hours(-2), + completedAt: hours(-1), + subtasks: { done: 1, total: 3 }, + blockedByCount: 2, + tagCount: 2, + }); + }); + + it("defaults the counts a card must not guess at", () => { + expect(taskFacts(row())).toEqual({ + dueAt: undefined, + completedAt: undefined, + subtasks: null, + blockedByCount: 0, + tagCount: 0, + }); + }); + + it("claims no attachments or estimate, because the list returns neither", () => { + // Honest absence. A plausible zero would have the card assert something + // the endpoint never told it. + const facts = taskFacts(row()) as Record; + expect(facts.attachmentCount).toBeUndefined(); + expect(facts.estimateMins).toBeUndefined(); + }); +}); + +describe("cardChips", () => { + it("turns a loaded row into the strip the board draws", () => { + expect( + cardChips( + row({ + due_at: hours(-2), + subtasks: { done: 1, total: 3 }, + blocked_by_count: 1, + tags: ["ops"], + }), + NOW, + ).map((c) => [c.key, c.label]), + ).toEqual([ + ["blocked", "1"], + ["due", "2h ago"], + ["subtasks", "1/3"], + ["tags", "1"], + ]); + }); + + it("leaves a plain task with a bare card", () => { + expect(cardChips(row(), NOW)).toEqual([]); + }); + + it("stops calling a finished task overdue", () => { + const chips = cardChips( + row({ due_at: hours(-48), completed_at: hours(-1) }), + NOW, + ); + expect(chips.map((c) => c.tone)).toEqual(["muted"]); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/card.ts b/workbench/control_plane/src/app/projects/lib/card.ts new file mode 100644 index 00000000..df465d39 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/card.ts @@ -0,0 +1,33 @@ +/** + * Projects · a task row, in the shared card's terms (WS-27s). + * + * The seam between `TaskRow` — snake_case, straight off the list endpoint — and + * `@/lib/taskCard`'s `TaskFacts`, which is deliberately neither app's row type. + * Keeping the translation here rather than inline in the board means the two + * surfaces that draw a card (board and list) cannot start disagreeing about + * which facts a task has, which is exactly how they drifted before. + * + * **Only fields the LIST endpoint actually returns.** `attachmentCount` and + * `estimateMins` are honestly absent: attachments are counted on the single + * task read (WS-27i) and there is no estimate column at all. Filling either + * with a plausible zero would make the card assert something it does not know. + */ + +import { type MetaChip, type TaskFacts, taskMeta } from "@/lib/taskCard"; + +import type { TaskRow } from "./api"; + +export function taskFacts(task: TaskRow): TaskFacts { + return { + dueAt: task.due_at, + completedAt: task.completed_at, + subtasks: task.subtasks ?? null, + blockedByCount: task.blocked_by_count ?? 0, + tagCount: task.tags?.length ?? 0, + }; +} + +/** The chips one row has earned. */ +export function cardChips(task: TaskRow, nowMs?: number): MetaChip[] { + return taskMeta(taskFacts(task), nowMs); +} diff --git a/workbench/control_plane/src/app/tasks/lib/utils.ts b/workbench/control_plane/src/app/tasks/lib/utils.ts index 45860f9e..e7650193 100644 --- a/workbench/control_plane/src/app/tasks/lib/utils.ts +++ b/workbench/control_plane/src/app/tasks/lib/utils.ts @@ -1,33 +1,21 @@ // Small presentation helpers for the GTD task UI. +import { isOverdue as overdue } from "@/lib/taskCard"; + import { Disposition, Energy, GtdItem, ProviderKind, Source } from "./types"; -/** Relative "time ago" / "in X" label for a due or created date. */ -export function relativeTime(iso: string | undefined, nowMs = Date.now()): string { - if (!iso) return ""; - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return ""; - const diffMin = Math.round((then - nowMs) / 60000); - const past = diffMin < 0; - const m = Math.abs(diffMin); - const fmt = (n: number, unit: string) => - past ? `${n}${unit} ago` : `in ${n}${unit}`; - if (m < 1) return "now"; - if (m < 60) return fmt(m, "m"); - const h = Math.round(m / 60); - if (h < 24) return fmt(h, "h"); - const d = Math.round(h / 24); - if (d < 7) return fmt(d, "d"); - const w = Math.round(d / 7); - if (w < 5) return fmt(w, "w"); - const mo = Math.round(d / 30); - return fmt(mo, "mo"); -} +// `relativeTime`, `durationLabel` and `initials` now live in `@/lib/taskCard`, +// which /projects draws its cards from too (WS-27s) — one definition of "2d +// ago" and one of what an avatar's letters are, so the two surfaces a member +// moves between hour to hour cannot describe the same task differently. +// Re-exported rather than moved at every call site: seventeen files import +// them from here, and a rename that touches seventeen files to change nothing +// is a diff nobody can review. +export { durationLabel, initials, relativeTime } from "@/lib/taskCard"; /** True if a hard-date item is overdue. */ export function isOverdue(item: GtdItem, nowMs = Date.now()): boolean { - if (!item.dueAt) return false; - return new Date(item.dueAt).getTime() < nowMs; + return overdue(item.dueAt, item.completedAt, nowMs); } /** Milliseconds elapsed since an ISO timestamp (wall-clock now). */ @@ -92,15 +80,6 @@ export function sourceBadge(source: Source, provider?: ProviderKind): { return { label, tone: "synced" }; } -/** Minutes → "10m" / "1h 30m". */ -export function durationLabel(mins?: number): string { - if (!mins) return ""; - if (mins < 60) return `${mins}m`; - const h = Math.floor(mins / 60); - const m = mins % 60; - return m ? `${h}h ${m}m` : `${h}h`; -} - /** ClickUp's API returns status names lower-cased ("to do", "in progress"); * its UI shows them title-cased. Present them the way the tool does — capitalize * each word's first letter, leaving the rest untouched (so an already-cased or @@ -177,15 +156,6 @@ export function detectDateHint(title: string): string | null { return hit ?? null; } -/** Initials for an avatar chip. */ -export function initials(name: string): string { - return name - .split(/\s+/) - .slice(0, 2) - .map((p) => p[0]?.toUpperCase() ?? "") - .join(""); -} - /** Deep link to the source email of an email-origin item (the email app * reads ?account= at hydrate and ?email= on mount). */ export function originEmailHref(origin?: { diff --git a/workbench/control_plane/src/components/TaskMeta.tsx b/workbench/control_plane/src/components/TaskMeta.tsx new file mode 100644 index 00000000..bb37ef43 --- /dev/null +++ b/workbench/control_plane/src/components/TaskMeta.tsx @@ -0,0 +1,98 @@ +"use client"; + +/** + * The chip row and avatar stack a task card draws (WS-27s). + * + * The one place a `MetaTone` becomes a colour. `lib/taskCard.ts` decides WHICH + * chips a task earns and what each one means; this decides what that looks + * like, in tokens, so `DESIGN_SYSTEM.md`'s "never write a colour" rule has + * exactly one file to hold rather than one per surface. + * + * Prop-driven and store-free on purpose: `/tasks` reads a Zustand store and + * `/projects` reads a REST list, and a shared component that knew about either + * would be shared in name only. + */ + +import Icon from "@/components/Icon"; +import { type MetaChip, type MetaTone, avatarStack, initials } from "@/lib/taskCard"; + +const TONE: Record = { + muted: "text-muted-foreground", + // Weight as well as colour: the chip already carries a different icon, and + // three signals is what makes "this is late" survive a colour-blind reader + // and a low-contrast monitor. + danger: "font-medium text-destructive", + accent: "text-primary", +}; + +/** The wrapping row of chips. Renders nothing at all when there are none. */ +export function TaskMeta({ + chips, + className = "", +}: { + chips: MetaChip[]; + className?: string; +}) { + if (chips.length === 0) return null; + return ( + + {chips.map((chip) => ( + + + {chip.label} + + ))} + + ); +} + +/** + * Overlapping initials, with a "+N" for whoever did not fit. + * + * The full list rides in `title` rather than being dropped: a shared task is + * the case where knowing the fourth name actually matters, and hovering is + * cheaper than opening the task to find out. + */ +export function AvatarStack({ + people, + max = 3, + label = (who) => who, + className = "", +}: { + people?: readonly string[] | null; + max?: number; + /** + * How an identifier reads to a human. Projects hands over email addresses + * and `agent:` handles; Tasks hands over display names, for which the + * default identity is already right. + */ + label?: (who: string) => string; + className?: string; +}) { + const { shown, extra } = avatarStack(people, max); + if (shown.length === 0 && extra === 0) return null; + return ( + + {shown.map((person) => ( + + {initials(label(person))} + + ))} + {extra > 0 ? ( + + +{extra} + + ) : null} + + ); +} diff --git a/workbench/control_plane/src/lib/taskCard.test.ts b/workbench/control_plane/src/lib/taskCard.test.ts new file mode 100644 index 00000000..71eb9faa --- /dev/null +++ b/workbench/control_plane/src/lib/taskCard.test.ts @@ -0,0 +1,284 @@ +/** + * WS-27s — the shared card vocabulary. + * + * These are the rules that make a Projects card readable at a glance, and every + * one of them has a plausible wrong version: + * + * * **overdue means past due AND still open.** The wrong version is a `<` on + * the date alone, which paints every finished task red forever — after which + * red stops meaning anything and the whole signal is spent. + * * **a zero earns no chip.** The wrong version renders "0" for subtasks, tags + * and attachments on every task that has none, which is most of them. + * * **order is fixed.** The wrong version builds chips in whatever order the + * object's keys arrive, so the same task reorders itself between renders and + * the reader has to read rather than scan. + * + * Pure functions, no DOM. The component that paints these is a `switch` over + * `tone` and has nothing left to get wrong. + */ + +import { describe, expect, it } from "vitest"; + +import { + avatarStack, + durationLabel, + initials, + isOverdue, + relativeTime, + taskMeta, +} from "./taskCard"; + +const NOW = Date.parse("2026-08-07T12:00:00Z"); +const hours = (n: number) => new Date(NOW + n * 3_600_000).toISOString(); + +// ── isOverdue ─────────────────────────────────────────────────────────────── + +describe("isOverdue", () => { + it("is true for a past due date on open work", () => { + expect(isOverdue(hours(-3), null, NOW)).toBe(true); + }); + + it("is false once the task is finished", () => { + // ⚠️ The rule the whole signal rests on. Without it every closed task with + // a past due date is permanently red. + expect(isOverdue(hours(-3), hours(-1), NOW)).toBe(false); + }); + + it("is false for a future due date", () => { + expect(isOverdue(hours(3), null, NOW)).toBe(false); + }); + + it("is false when there is no due date at all", () => { + expect(isOverdue(null, null, NOW)).toBe(false); + expect(isOverdue(undefined, undefined, NOW)).toBe(false); + }); + + it("is false rather than true for an unparseable date", () => { + // A card that cannot read the date must not claim the task is late. + expect(isOverdue("not a date", null, NOW)).toBe(false); + }); +}); + +// ── the small helpers ─────────────────────────────────────────────────────── + +describe("relativeTime", () => { + it("reads backwards for the past and forwards for the future", () => { + expect(relativeTime(hours(-2), NOW)).toBe("2h ago"); + expect(relativeTime(hours(2), NOW)).toBe("in 2h"); + }); + + it("returns nothing for a missing or unreadable date", () => { + expect(relativeTime(null, NOW)).toBe(""); + expect(relativeTime(undefined, NOW)).toBe(""); + expect(relativeTime("tomorrow-ish", NOW)).toBe(""); + }); + + it("climbs units rather than printing 4000m", () => { + expect(relativeTime(hours(-24 * 3), NOW)).toBe("3d ago"); + expect(relativeTime(hours(-24 * 14), NOW)).toBe("2w ago"); + expect(relativeTime(hours(-24 * 90), NOW)).toBe("3mo ago"); + }); +}); + +describe("durationLabel", () => { + it("stays in minutes under an hour", () => { + expect(durationLabel(45)).toBe("45m"); + }); + + it("drops a zero minute part", () => { + expect(durationLabel(120)).toBe("2h"); + expect(durationLabel(90)).toBe("1h 30m"); + }); + + it("is empty for nothing, so the caller can test the string", () => { + expect(durationLabel(0)).toBe(""); + expect(durationLabel(null)).toBe(""); + expect(durationLabel(undefined)).toBe(""); + }); +}); + +describe("initials", () => { + it("takes at most two words", () => { + expect(initials("Priya Sharma")).toBe("PS"); + expect(initials("Jean Luc Picard")).toBe("JL"); + }); + + it("survives a one-word name and an empty one", () => { + expect(initials("priya")).toBe("P"); + expect(initials("")).toBe(""); + }); + + it("reads an email local part as the two words it is", () => { + // ⚠️ Half the people this draws have no display name — Projects identifies + // an assignee by address. "P" is a worse avatar than "PS". + expect(initials("priya.sharma")).toBe("PS"); + expect(initials("arjun_rao")).toBe("AR"); + }); + + it("does not treat a hyphen as a separator", () => { + // "Jean-Luc Picard" is two names, not three; splitting it gives "JL". + expect(initials("Jean-Luc Picard")).toBe("JP"); + }); +}); + +// ── taskMeta ──────────────────────────────────────────────────────────────── + +const keys = (facts: Parameters[0]) => + taskMeta(facts, NOW).map((c) => c.key); + +describe("taskMeta", () => { + it("gives a bare task no chips at all", () => { + expect(taskMeta({}, NOW)).toEqual([]); + }); + + it("draws nothing for a zero count", () => { + // ⚠️ Most tasks have no subtasks, no tags and no attachments. Chips for + // those would be the majority of every meta row. + expect( + keys({ + subtasks: { done: 0, total: 0 }, + blockedByCount: 0, + tagCount: 0, + attachmentCount: 0, + estimateMins: 0, + }), + ).toEqual([]); + }); + + it("orders the chips blocked → due → progress → the quiet counts", () => { + // ⚠️ Fixed order is what makes the row scannable. Built from an object + // whose keys are in a DIFFERENT order, so a key-order-dependent + // implementation cannot pass by coincidence. + expect( + keys({ + estimateMins: 30, + attachmentCount: 2, + tagCount: 1, + subtasks: { done: 1, total: 2 }, + dueAt: hours(4), + blockedByCount: 1, + }), + ).toEqual(["blocked", "due", "subtasks", "tags", "attachments", "estimate"]); + }); + + it("marks an overdue task with a different icon, not only a colour", () => { + // A tone alone excludes anyone who cannot see the difference between + // muted and destructive. + const late = taskMeta({ dueAt: hours(-4) }, NOW)[0]; + const soon = taskMeta({ dueAt: hours(4) }, NOW)[0]; + expect([late.icon, late.tone]).toEqual(["AlertTriangle", "danger"]); + expect([soon.icon, soon.tone]).toEqual(["Clock", "muted"]); + }); + + it("stops calling a finished task overdue", () => { + const chip = taskMeta( + { dueAt: hours(-4), completedAt: hours(-1) }, + NOW, + )[0]; + expect(chip.tone).toBe("muted"); + expect(chip.icon).toBe("Clock"); + }); + + it("counts only OPEN blockers, which is the number it was handed", () => { + const chip = taskMeta({ blockedByCount: 2 }, NOW)[0]; + expect(chip.label).toBe("2"); + expect(chip.tone).toBe("danger"); + expect(chip.title).toBe("Blocked by 2 unfinished tasks"); + }); + + it("says 'task' rather than 'tasks' for one blocker", () => { + expect(taskMeta({ blockedByCount: 1 }, NOW)[0].title).toBe( + "Blocked by 1 unfinished task", + ); + }); + + it("reads subtask progress as done-over-total", () => { + const chip = taskMeta({ subtasks: { done: 2, total: 5 } }, NOW)[0]; + expect(chip.label).toBe("2/5"); + expect(chip.title).toBe("2 of 5 subtasks done"); + expect(chip.tone).toBe("muted"); + }); + + it("lifts the tone once every subtask is done", () => { + // A parent whose checklist is complete is almost always a task somebody + // forgot to close. + expect(taskMeta({ subtasks: { done: 3, total: 3 } }, NOW)[0].tone).toBe( + "accent", + ); + }); + + it("never invents a chip tone the component cannot paint", () => { + const every = taskMeta( + { + dueAt: hours(-1), + blockedByCount: 1, + subtasks: { done: 4, total: 4 }, + tagCount: 3, + attachmentCount: 1, + estimateMins: 90, + }, + NOW, + ); + expect(every).toHaveLength(6); + for (const chip of every) { + expect(["muted", "danger", "accent"]).toContain(chip.tone); + expect(chip.title.length).toBeGreaterThan(chip.label.length); + expect(chip.icon).toMatch(/^[A-Z]/); + } + }); + + it("gives every chip a distinct key", () => { + const all = taskMeta( + { + dueAt: hours(1), + blockedByCount: 1, + subtasks: { done: 1, total: 2 }, + tagCount: 1, + attachmentCount: 1, + estimateMins: 5, + }, + NOW, + ); + expect(new Set(all.map((c) => c.key)).size).toBe(all.length); + }); +}); + +// ── avatarStack ───────────────────────────────────────────────────────────── + +describe("avatarStack", () => { + it("shows everyone when the list is short", () => { + expect(avatarStack(["a@x.io", "b@x.io"])).toEqual({ + shown: ["a@x.io", "b@x.io"], + extra: 0, + }); + }); + + it("caps the row and counts the remainder", () => { + // ⚠️ Uncapped, nine assignees push every other chip off a 288px column. + expect(avatarStack(["a", "b", "c", "d", "e"])).toEqual({ + shown: ["a", "b", "c"], + extra: 2, + }); + }); + + it("handles nobody without a crash or a phantom +0", () => { + expect(avatarStack(undefined)).toEqual({ shown: [], extra: 0 }); + expect(avatarStack([])).toEqual({ shown: [], extra: 0 }); + }); + + it("adds no +N when the list is exactly the cap", () => { + // The off-by-one that shows "+0" on a full row. + expect(avatarStack(["a", "b", "c"], 3)).toEqual({ + shown: ["a", "b", "c"], + extra: 0, + }); + }); + + it("counts everyone as extra when the cap is zero", () => { + // ⚠️ `length - max` with `max` of 0 is the whole list, which is right by + // accident; a caller that passed a negative cap would get more than there + // are people. Both take the explicit branch. + expect(avatarStack(["a", "b"], 0)).toEqual({ shown: [], extra: 2 }); + expect(avatarStack(["a", "b"], -1)).toEqual({ shown: [], extra: 2 }); + }); +}); diff --git a/workbench/control_plane/src/lib/taskCard.ts b/workbench/control_plane/src/lib/taskCard.ts new file mode 100644 index 00000000..ceb7abe1 --- /dev/null +++ b/workbench/control_plane/src/lib/taskCard.ts @@ -0,0 +1,231 @@ +/** + * The task card's vocabulary — shared by /tasks and /projects (WS-27s). + * + * Two apps in this workspace draw a task. `/tasks` is the GTD surface over + * `gtd_items`; `/projects` is the project-management surface over `pm_tasks`. + * They are different stores with different rules, and D-PM-6 has `/tasks` + * retiring onto `pm_tasks` at WS-27h — so a member will use both, sometimes on + * the same day, and a task should not look like a different KIND of thing + * depending on which tab it is in. + * + * **What is shared is the vocabulary, not the component.** Porting + * `/tasks`'s `TaskCard` wholesale was the obvious move and the wrong one: it is + * bound to `useTaskStore` and to `GtdItem`'s fields — `energy`, `deepWork`, + * `disposition` — none of which `pm_tasks` has or should grow. It would also + * die with `gtd_items` at WS-27h, taking the Projects board with it. So what + * moves here is the part that is genuinely the same on both sides: how a + * duration reads, what counts as overdue, and which chips a task earns. + * + * `taskMeta` returns DESCRIPTORS, not JSX. Two reasons, and the second is the + * one that matters: + * + * 1. it is testable without a DOM, so the rules below are pinned by assertions + * rather than by looking at a screenshot; + * 2. the tone is a NAME (`"danger"`), not a class. `DESIGN_SYSTEM.md` forbids + * writing a colour, and a descriptor that carried `text-destructive` would + * quietly move that decision into a file with no theme. + */ + +/** Which of the app's semantic tones a chip is painted in. */ +export type MetaTone = "muted" | "danger" | "accent"; + +export interface MetaChip { + /** Stable React key, and what a test asserts on. */ + key: string; + /** A Lucide name for ``; the active theme picks the pack. */ + icon: string; + label: string; + tone: MetaTone; + /** Long form, for `title=` — a chip reading "2/5" needs to say what of. */ + title: string; +} + +/** Everything a card can draw, in terms neither app's row type owns. */ +export interface TaskFacts { + dueAt?: string | null; + completedAt?: string | null; + /** WS-27s — filled for every row of the list endpoint. */ + subtasks?: { done: number; total: number } | null; + blockedByCount?: number | null; + tagCount?: number | null; + attachmentCount?: number | null; + estimateMins?: number | null; +} + +/** Relative "time ago" / "in X" label for a due or created date. */ +export function relativeTime(iso: string | undefined | null, nowMs = Date.now()): string { + if (!iso) return ""; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const diffMin = Math.round((then - nowMs) / 60000); + const past = diffMin < 0; + const m = Math.abs(diffMin); + const fmt = (n: number, unit: string) => + past ? `${n}${unit} ago` : `in ${n}${unit}`; + if (m < 1) return "now"; + if (m < 60) return fmt(m, "m"); + const h = Math.round(m / 60); + if (h < 24) return fmt(h, "h"); + const d = Math.round(h / 24); + if (d < 7) return fmt(d, "d"); + const w = Math.round(d / 7); + if (w < 5) return fmt(w, "w"); + const mo = Math.round(d / 30); + return fmt(mo, "mo"); +} + +/** Minutes → "10m" / "1h 30m". */ +export function durationLabel(mins?: number | null): string { + if (!mins) return ""; + if (mins < 60) return `${mins}m`; + const h = Math.floor(mins / 60); + const m = mins % 60; + return m ? `${h}h ${m}m` : `${h}h`; +} + +/** + * Initials for an avatar chip. + * + * Splits on dots and underscores as well as spaces, because half the people + * this draws are identified by an email local part rather than a display name: + * `priya.sharma` is two words wearing a separator, and "P" is a worse avatar + * than "PS". Hyphens are deliberately NOT separators — "Jean-Luc Picard" is two + * names, not three. + */ +export function initials(name: string): string { + return name + .split(/[\s._]+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? "") + .join(""); +} + +/** + * Past its due date AND still open. + * + * The second half is the whole point. A finished task with a last-Tuesday due + * date is not overdue, and colouring it red forever is how a board teaches + * people to ignore red — the same rule the `overdue` filter enforces in SQL, so + * that a card and the filter that selected it cannot disagree. + */ +export function isOverdue( + dueAt?: string | null, + completedAt?: string | null, + nowMs = Date.now(), +): boolean { + if (!dueAt || completedAt) return false; + const due = new Date(dueAt).getTime(); + if (Number.isNaN(due)) return false; + return due < nowMs; +} + +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +/** + * The chips a task has earned, in reading order. + * + * **Order is deliberate and fixed**, because a card is narrow and the tail + * wraps or truncates: blocked first (it is the one fact that says do not start + * this), then due (the one that says when), then progress, then the quieter + * counts. A card whose chip order depends on the data is a card you have to + * read rather than scan. + * + * **A zero earns no chip.** "0 subtasks", "0 tags" and "0 attachments" are the + * normal state of most tasks; drawing them turns the meta row into noise and + * pushes the chips that mean something off the edge. + */ +export function taskMeta(facts: TaskFacts, nowMs = Date.now()): MetaChip[] { + const chips: MetaChip[] = []; + + const blocked = facts.blockedByCount ?? 0; + if (blocked > 0) { + chips.push({ + key: "blocked", + icon: "Ban", + label: String(blocked), + tone: "danger", + title: `Blocked by ${plural(blocked, "unfinished task")}`, + }); + } + + const due = relativeTime(facts.dueAt, nowMs); + if (due) { + const late = isOverdue(facts.dueAt, facts.completedAt, nowMs); + chips.push({ + key: "due", + icon: late ? "AlertTriangle" : "Clock", + label: due, + tone: late ? "danger" : "muted", + title: late ? `Overdue — was due ${due}` : `Due ${due}`, + }); + } + + const subtasks = facts.subtasks; + if (subtasks && subtasks.total > 0) { + const complete = subtasks.done >= subtasks.total; + chips.push({ + key: "subtasks", + icon: "ListTree", + label: `${subtasks.done}/${subtasks.total}`, + // A finished checklist is worth noticing — it usually means the parent + // is ready to close and nobody has closed it. + tone: complete ? "accent" : "muted", + title: `${subtasks.done} of ${plural(subtasks.total, "subtask")} done`, + }); + } + + const tags = facts.tagCount ?? 0; + if (tags > 0) { + chips.push({ + key: "tags", + icon: "Tag", + label: String(tags), + tone: "muted", + title: plural(tags, "tag"), + }); + } + + const attachments = facts.attachmentCount ?? 0; + if (attachments > 0) { + chips.push({ + key: "attachments", + icon: "Paperclip", + label: String(attachments), + tone: "muted", + title: plural(attachments, "attachment"), + }); + } + + const estimate = durationLabel(facts.estimateMins); + if (estimate) { + chips.push({ + key: "estimate", + icon: "Hourglass", + label: estimate, + tone: "muted", + title: `Estimated ${estimate}`, + }); + } + + return chips; +} + +/** + * The avatars to draw, and how many were left out. + * + * Capped rather than wrapped: a task with nine assignees would otherwise push + * everything else off a 288px column, and the ninth name is not the thing the + * reader is scanning for. `extra` carries the count so "+6" can say so. + */ +export function avatarStack( + people: readonly string[] | undefined | null, + max = 3, +): { shown: string[]; extra: number } { + const all = people ?? []; + // `max <= 0` is its own case: `slice(0, 0)` is right but `length - 0` is not + // — the remainder would be counted from a cap that was never applied, and a + // caller asking for no avatars would get "+0" instead of "+5". + if (max <= 0) return { shown: [], extra: all.length }; + return { shown: all.slice(0, max), extra: Math.max(0, all.length - max) }; +} From f509ad6117a72cc9ab915e043a8c03efc7213cb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 04:24:26 +0000 Subject: [PATCH 04/22] =?UTF-8?q?feat(WS-27q):=20the=20calendar=20?= =?UTF-8?q?=E2=80=94=20a=20window,=20not=20a=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ninth backlog row, and the first view that cannot be paginated. A month with ninety tasks read at page_size=50 draws forty and leaves the other days looking EMPTY. A short page announces itself; a short month does not, and nobody investigates a quiet week. So GET /projects/calendar takes a window, returns everything in it, and says `truncated` when the cap is reached rather than handing back a plausible-looking month. start_date has existed since migration 146 with no surface — the same complaint §11.14 makes about links, and the reason the calendar was the view worth building: a task is a bar from its start to its due date, not a dot. Overlap, not equality. A task starting Monday and due Friday belongs on Wednesday's cell; `due_at BETWEEN` puts it on Friday alone, which is exactly the week somebody looks at Wednesday and concludes they are free. A task with NEITHER date falls out through NULL — correct and invisible — so `undated` counts them with the same filters and the view says so. The window is read in UTC and the client asks for a day of slack. A start_date is a floating date and a due_at is an instant; no single frame makes both exact, so the server over-selects and the browser places. start_date is anchored with AT TIME ZONE 'UTC' rather than CAST(… AS timestamptz), which would silently read the connection's TimeZone — pinned by a live run with the session set to America/Los_Angeles. Filters carry across the switch. FastAPI ignores an unknown query parameter, so a filter the board sends and the calendar does not declare is not an error — it is a filter that quietly stops applying, which reads as the filter breaking. A test asserts the calendar's parameter set covers the list's minus a named, reasoned exclusion list. due_before is excluded (it bounds the same column as the window); overdue is not, because "already late" is a fact about the status as much as the date. No second write path: dragging a card is PATCH /tasks/{id}. Dragging a bar moves the whole bar and keeps its time of day — writing only the dropped date leaves the other end behind and inverts the interval the moment you drag left. new Date("2026-08-07") is midnight UTC, the 6th west of Greenwich, so the grid works in YYYY-MM-DD keys throughout. That claim is only behaviourally testable west of Greenwich, so the suite runs in four timezones AND pins the rule structurally, because CI runs in one. Building it found a hole in the fake: overdue's date half (due_at < now()) had never been mirrored, so every overdue test since WS-27k was asserting only the status half and would have passed with the comparison deleted. Verified: 605 backend + 1025 frontend tests; calendar grid green in UTC, Asia/Kolkata, America/Los_Angeles and Pacific/Kiritimati; ruff and xenon clean; theme conformance green; next build clean; 14 mutants killed across both halves, one equivalent mutant found and the code simplified rather than the test kept; live Postgres 16 run all green — and it caught a wrong assertion of mine about sort order before it reached the suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../specs/project_management_app.md | 66 ++- .../gateway/routes/projects/__init__.py | 1 + .../gateway/routes/projects/calendar.py | 286 ++++++++++ tests/unit/_projects_fakes.py | 59 ++ tests/unit/test_projects_calendar.py | 524 ++++++++++++++++++ .../app/projects/components/CalendarView.tsx | 163 ++++++ .../control_plane/src/app/projects/lib/api.ts | 32 ++ .../src/app/projects/lib/calendar.test.ts | 364 ++++++++++++ .../src/app/projects/lib/calendar.ts | 233 ++++++++ .../control_plane/src/app/projects/page.tsx | 92 ++- 10 files changed, 1817 insertions(+), 3 deletions(-) create mode 100644 apps/services/gateway/gateway/routes/projects/calendar.py create mode 100644 tests/unit/test_projects_calendar.py create mode 100644 workbench/control_plane/src/app/projects/components/CalendarView.tsx create mode 100644 workbench/control_plane/src/app/projects/lib/calendar.test.ts create mode 100644 workbench/control_plane/src/app/projects/lib/calendar.ts diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index dad2a2cd..d8e95617 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -947,7 +947,7 @@ interesting it is to build. | 6 | ~~**Bulk edit / multi-select**~~ | — | **WS-27n ✅ BUILT 2026-08-07 · unblocks g** | | 7 | ~~**Recurring tasks**~~ | — | **WS-27o ✅ BUILT 2026-08-07** | | 8 | ~~**Dependency and subtask UI**~~ | — | **WS-27p ✅ BUILT 2026-08-07** | -| 9 | **Calendar / timeline view** | The third view ClickUp users actually use, after list and board | **WS-27q** | +| 9 | ~~**Calendar / timeline view**~~ | — | **WS-27q ✅ BUILT 2026-08-08** | | 10 | **Global task search** | `?q=` exists on the list endpoint; there is no search surface | **WS-27r** | | 11 | ~~**The card looks nothing like /tasks'**~~ | — | **WS-27s ✅ BUILT 2026-08-07** | @@ -1658,3 +1658,67 @@ applied again: every clause in the two roll-ups is mirrored **only when the stat it**, and which end of a `blocks` link is the blocked one is read off the SQL rather than assumed. A mirror that filters unconditionally agrees with itself no matter what the route stops emitting, which is how a deleted WHERE clause survives a green suite. + +### 11.16 WS-27q — the calendar (built 2026-08-08) + +The ninth backlog row, and the first view that **cannot be a page**. + +**`/projects/tasks` is paginated, which is right for a list and catastrophic for a +calendar.** A month with ninety tasks read at `page_size=50` draws forty of them and leaves +the other days looking EMPTY. A short page announces itself — "page 2 of 3"; a short month +does not, and nobody investigates a quiet week. So `GET /projects/calendar?from=&to=` takes a +WINDOW, returns everything in it, and when the cap is reached says `truncated` rather than +handing back a plausible-looking month. + +**`start_date` has existed since migration 146 and no surface had ever shown it.** The same +complaint §11.14 makes about links, and the reason a calendar is the view that needed +building: a task is a BAR from its start to its due date, not a dot on one day. + +**Overlap, not equality.** A task that starts Monday and is due Friday belongs on Wednesday's +cell. `due_at BETWEEN :from AND :to` — the implementation everyone writes first — puts it on +Friday alone, which is exactly the week somebody looks at Wednesday and concludes they are +free. The clause is `coalesce(start_date, due_at) < :to AND coalesce(due_at, start_date) >= +:from`, so a task with one date is a point and a task with both is a bar. + +**A task with NEITHER date falls out through NULL**, which is correct and invisible — so +`undated` counts them with the SAME filters and the view says "12 unscheduled". Dropping them +silently is how a calendar comes to look like the whole workspace while showing a third of it. + +**The window is read in UTC and the client asks for a day of slack.** A `start_date` is a +floating calendar date and a `due_at` is an instant; no single frame makes both exact, since a +`due_at` of 23:00Z sits on the next day in IST and the previous one in PST. Rather than +pretend, the server OVER-selects and the browser — the only party that knows the viewer's +timezone — does the placement. `start_date` is anchored with `AT TIME ZONE 'UTC'` rather than +`CAST(… AS timestamptz)`, which would silently read the connection's `TimeZone`: a session +setting no caller controls and no test would notice changing. A live run with the session set +to `America/Los_Angeles` pins that. + +**Filters carry across the switch, and one is deliberately excluded.** Board and calendar are +the same question in different shapes, so a filtered board that shows everything on the +calendar reads as the FILTER breaking. `due_before` stays out because it bounds the same +column as the window and the loser of a contradiction leaves no trace; `overdue` looks like +its twin and is not — "already late" is a fact about the status as much as the date. Since +FastAPI **ignores an unknown query parameter**, a dropped filter is not an error but a silent +behaviour change, so a test asserts the calendar's parameter set covers the list's minus a +named, reasoned exclusion list. + +**No second write path.** Dragging a card is `PATCH /tasks/{id}` — the same validation, the +same `field_change` activity, the same revert. A `POST /calendar/move` is how two paths start +disagreeing about what is allowed. + +**Dragging a bar moves the WHOLE bar, and keeps the time of day.** The span is an estimate +somebody made; a drag that silently shortens it to one day destroys information the user did +not offer to change. Writing only the dropped date — the version every calendar implements +first — leaves the other end behind and inverts the interval the moment you drag left. "Due +Friday at 5" dragged to Monday is due Monday at 5. + +**`new Date("2026-08-07")` is midnight UTC**, which is the 6th anywhere west of Greenwich, and +routing a `start_date` through it is the single most common way a calendar loses a day. The +grid works in `YYYY-MM-DD` keys throughout. That claim is only *behaviourally* testable west +of Greenwich — in UTC and everywhere east, the buggy version happens to give the same answer — +so the suite runs in four timezones AND pins the rule structurally, because CI runs in one. + +**Building it found a hole in the test fake.** `overdue`'s date half (`due_at < now()`) had +never been mirrored, so every `overdue` test since WS-27k was really asserting only the +status half and would have passed with the date comparison deleted. Teaching the fake `< +now()` killed that mutant on the list endpoint as well as the calendar. diff --git a/apps/services/gateway/gateway/routes/projects/__init__.py b/apps/services/gateway/gateway/routes/projects/__init__.py index 8c2a5b2d..cfe328e0 100644 --- a/apps/services/gateway/gateway/routes/projects/__init__.py +++ b/apps/services/gateway/gateway/routes/projects/__init__.py @@ -25,6 +25,7 @@ from gateway.routes.projects import admin as _admin # noqa: F401 from gateway.routes.projects import attachments as _attachments # noqa: F401 from gateway.routes.projects import bulk as _bulk # noqa: F401 +from gateway.routes.projects import calendar as _calendar # noqa: F401 from gateway.routes.projects import custom_fields as _custom_fields # noqa: F401 from gateway.routes.projects import import_clickup as _import_clickup # noqa: F401 from gateway.routes.projects import import_tasks as _import_tasks # noqa: F401 diff --git a/apps/services/gateway/gateway/routes/projects/calendar.py b/apps/services/gateway/gateway/routes/projects/calendar.py new file mode 100644 index 00000000..cbc09b90 --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/calendar.py @@ -0,0 +1,286 @@ +"""Projects · the calendar window (WS-27q). + +Spec: ``ai-company-brain/specs/project_management_app.md`` §11.2 item 9, §11.16. + + GET /projects/calendar?from=2026-08-01&to=2026-09-01 → every task in view + +*"The third view ClickUp users actually use, after list and board."* + +**A window, not a page — and that is the whole reason this is a new endpoint.** +`/projects/tasks` is paginated, which is right for a list and catastrophic for a +calendar: a month with 90 tasks read at `page_size=50` draws forty of them and +leaves the rest of the days looking EMPTY. A short page announces itself ("page +2 of 3"); a short month does not. So the window is the unit, everything in it +comes back, and when the cap is hit the response SAYS so rather than quietly +handing back a plausible-looking month. + +**`start_date` has existed since migration 146 and no surface has ever shown +it.** The same complaint §11.14 makes about links: a column that cannot be seen +is a promise the product does not keep. A calendar is the view that needs it, +because a task is a BAR from its start to its due date, not a dot on one day. + +**Overlap, not equality.** A task that starts on Monday and is due on Friday +belongs on Wednesday's cell too. Filtering on `due_at BETWEEN` — the obvious +implementation — puts it on Friday alone, which is exactly the week where +somebody looks at Wednesday and concludes they are free. + +**A task with NEITHER date is not on the calendar, and the count says so.** +Dropping them silently is how a calendar comes to look like the whole workspace +when it is showing a third of it; `undated` is what lets the view admit it. + +**The window is read in UTC and the client is expected to ask for slack.** A +`start_date` is a floating calendar date and `due_at` is an instant, so no +single frame makes both exact — a `due_at` of 23:00Z sits on the next day in +IST and the previous one in PST. Rather than pretend, the server OVER-selects +against a UTC reading of the window and the browser, which is the only party +that knows the viewer's timezone, does the placement. `calendarWindow()` on the +client adds the day of slack that makes that safe. + +**No new write path.** Dragging a task to another day is a `PATCH /tasks/{id}` +of `start_date` and `due_at` — the same validation, the same `field_change` +activity, the same revert. A `POST /calendar/move` would be a second way to +edit a task, which is how the two start disagreeing about what is allowed. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from typing import Any + +from acb_auth import UserContext, get_current_user +from fastapi import Depends, HTTPException, Query +from gateway.routes.projects.core import ( + TaskModel, + _get_db, + load_visible_project, + resolve_visibility, + router, + row_to_dict, + task_visibility_clause, +) +from gateway.routes.projects.filters import ( + attach_assignees, + attach_relation_counts, + build_task_filters, +) +from sqlalchemy import text + +#: The widest window that may be asked for, in days. +#: +#: A year plus slack, so a year view is possible and an unbounded scan of an +#: imported workspace is not. Refused with a 422 rather than clamped: a client +#: that asked for five years and silently got one would draw four empty ones. +MAX_WINDOW_DAYS = 400 + +#: The most tasks one window returns. +#: +#: Reached, the response sets `truncated` and the view says so. **Silence is the +#: only unacceptable behaviour here** — a calendar missing a third of its tasks +#: looks exactly like a calendar with fewer tasks, and nobody investigates a +#: quiet week. +MAX_WINDOW_ROWS = 1000 + + +def parse_day(raw: str, *, field: str) -> date: + """A ``YYYY-MM-DD`` query parameter → a real ``date``. + + A **date**, not a timestamp: the window's unit is the day, and accepting + `2026-08-01T13:45:00+05:30` would invite the caller to believe the edge is + honoured to the minute when the whole contract is that the server + over-selects and the browser places (see the module docstring). + + A bad value is a 422 naming the format, for the reason `parse_when` gives: + `from=august` is the client's mistake and deserves to be told so. + """ + try: + return date.fromisoformat(raw.strip()) + except ValueError: + raise HTTPException( + status_code=422, + detail=f"'{raw}' is not a valid {field}. " + f"Expected a calendar date, e.g. 2026-08-01.", + ) from None + + +def window_bounds(raw_from: str, raw_to: str) -> tuple[datetime, datetime]: + """The window's half-open instant bounds, ``[from, to)``, read in UTC. + + **Half-open on purpose.** A month runs `2026-08-01` to `2026-09-01`, so two + consecutive windows tile without a task landing in both — an inclusive end + would double-count every task due on the last day, and a calendar that + disagrees with itself across a page turn is worse than one that is slightly + conservative at the edge. + """ + start = parse_day(raw_from, field="from") + end = parse_day(raw_to, field="to") + if end <= start: + raise HTTPException( + status_code=422, + detail=f"'to' ({end}) must be after 'from' ({start}).", + ) + if (end - start).days > MAX_WINDOW_DAYS: + raise HTTPException( + status_code=422, + detail=f"That window is {(end - start).days} days. " + f"The maximum is {MAX_WINDOW_DAYS}.", + ) + return ( + datetime.combine(start, datetime.min.time(), tzinfo=UTC), + datetime.combine(end, datetime.min.time(), tzinfo=UTC), + ) + + +#: A task's scheduled interval overlaps the window. +#: +#: `start` is `coalesce(start_date, due_at)` and `end` is `coalesce(due_at, +#: start_date)`, so a task with one date is a POINT and a task with both is a +#: bar. Overlap is then the standard `start < :to AND end >= :from`. +#: +#: **A task with neither date drops out on its own**, because both coalesces are +#: NULL and every comparison against NULL is NULL rather than TRUE. That is +#: correct and it is also invisible, so `_UNDATED_SQL` counts them separately +#: and a test pins the behaviour rather than trusting the reading. +#: +#: `start_date` is anchored to UTC explicitly rather than through `CAST(… AS +#: timestamptz)`, which would silently use the connection's `TimeZone` — a +#: session setting no caller controls and no test would notice changing. +OVERLAPS = ( + "coalesce(CAST(t.start_date AS timestamp) AT TIME ZONE 'UTC', t.due_at)" + " < :window_to" + " AND coalesce(t.due_at, CAST(t.start_date AS timestamp) AT TIME ZONE 'UTC')" + " >= :window_from" +) + +#: Neither date set — the tasks a calendar structurally cannot show. +UNDATED = "t.start_date IS NULL AND t.due_at IS NULL" + + +def _subtree_clause() -> str: + return ( + "t.project_id IN (" + " WITH RECURSIVE sub AS (" + " SELECT id FROM pm_projects WHERE id = CAST(:pid AS uuid)" + " UNION ALL" + " SELECT p.id FROM pm_projects p JOIN sub s" + " ON p.parent_project_id = s.id" + " ) SELECT id FROM sub)" + ) + + +@router.get("/calendar") +async def get_calendar( + user: UserContext = Depends(get_current_user), + # `from` is a Python keyword, so the wire name is set by alias rather than + # by renaming the query parameter to something a caller would have to guess. + date_from: str = Query("", alias="from"), + date_to: str = Query("", alias="to"), + project_id: str | None = None, + include_subtree: bool = False, + # The board's filters, verbatim. A calendar that ignored them would show + # everything the moment somebody switched view, which reads as a bug in the + # filter rather than an absence in the calendar. + status_id: str | None = None, + status_category: str | None = None, + assignee: str | None = None, + assignees: str | None = None, + unassigned: bool = False, + overdue: bool = False, + importance_gte: int | None = None, + q: str | None = None, + tags: str | None = None, + tags_all: str | None = None, + include_archived: bool = False, +) -> dict: + """Every visible task whose schedule overlaps ``[from, to)``. + + The filters are the board's, applied by the same pure builder, so switching + from board to calendar changes the SHAPE of what is on screen and never the + SET — the rule §11.8 states for list and board, extended to the third view. + + **`due_before` is the one filter deliberately not accepted**, because it + duplicates the window: two ways to bound the same column that can + contradict, where the losing one vanishes without a word. `overdue` looks + like the same objection and is not — "already late" is a fact about the + status as much as the date, it composes with any window, and dropping it + would make a board filtered to overdue work show everything the moment + somebody switched to the calendar. + """ + window_from, window_to = window_bounds(date_from, date_to) + + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + clauses: list[str] = [task_visibility_clause(vis)] + params: dict[str, Any] = dict(vis.params) + + if project_id: + # Seeing the project is required to filter by it (R5): an + # unreadable id is a 404, never an empty calendar, which would + # confirm the project exists and is simply quiet. + await load_visible_project(db, vis, project_id) + clauses.append( + _subtree_clause() if include_subtree + else "t.project_id = CAST(:pid AS uuid)" + ) + params["pid"] = project_id + + extra_clauses, extra_params = build_task_filters( + status_id=status_id, status_category=status_category, + assignee=assignee, assignees=assignees, unassigned=unassigned, + overdue=overdue, importance_gte=importance_gte, q=q, tags=tags, + tags_all=tags_all, include_archived=include_archived, + ) + clauses.extend(extra_clauses) + params.update(extra_params) + + scoped = " AND ".join(clauses) + params["window_from"] = window_from + params["window_to"] = window_to + + rows = (await db.execute( + text( + f"SELECT t.* FROM pm_tasks t WHERE {scoped} AND {OVERLAPS} " + # Sorted so a day's cell is stable between loads: the interval's + # start, then the task number. An unordered calendar reshuffles + # every cell on refresh, which reads as the data having changed. + f"ORDER BY coalesce(t.start_date, CAST(t.due_at AS date)), " + f" t.task_number NULLS LAST, t.id " + f"LIMIT :cap" + ), + {**params, "cap": MAX_WINDOW_ROWS + 1}, + )).fetchall() + + truncated = len(rows) > MAX_WINDOW_ROWS + window_rows = [ + row_to_dict(r, TaskModel) for r in rows[:MAX_WINDOW_ROWS] + ] + await attach_assignees(db, window_rows) + await attach_relation_counts(db, window_rows) + + # Counted with the SAME filters, so "12 unscheduled" means twelve of the + # tasks you are looking at — not twelve somewhere in the workspace. + undated = (await db.execute( + text(f"SELECT count(*) FROM pm_tasks t WHERE {scoped} AND {UNDATED}"), + params, + )).scalar() or 0 + + return { + "from": window_from.date().isoformat(), + "to": window_to.date().isoformat(), + "rows": window_rows, + "truncated": truncated, + "cap": MAX_WINDOW_ROWS, + "undated": int(undated), + } + finally: + await db.close() + + +__all__ = [ + "MAX_WINDOW_DAYS", + "MAX_WINDOW_ROWS", + "OVERLAPS", + "UNDATED", + "parse_day", + "window_bounds", +] diff --git a/tests/unit/_projects_fakes.py b/tests/unit/_projects_fakes.py index 5749ef18..eb5a103e 100644 --- a/tests/unit/_projects_fakes.py +++ b/tests/unit/_projects_fakes.py @@ -72,6 +72,17 @@ _IS_NULL = re.compile(r"\b(?:\w+\.)?(\w+)\s+IS\s+(NOT\s+)?NULL", re.I) #: `` ILIKE :q`` _ILIKE = re.compile(r"(?:\w+\.)?(\w+)\s+ILIKE\s+:(\w+)", re.I) +#: `` < now()`` — the date half of the `overdue` filter. Unmirrored until +#: WS-27q, which meant every `overdue` test was really only asserting the +#: status half and would have passed with the date comparison deleted. +_NOW_LT = re.compile(r"\b(?:\w+\.)?(\w+)\s*<\s*now\(\)", re.I) +#: WS-27q's calendar window: ``coalesce(, ) < :window_to``. The captured +#: expression is what says WHICH interval endpoint the comparison is about, so +#: the mirror reads the SQL's coalesce order rather than assuming one. +_WINDOW_CMP = re.compile( + r"coalesce\(([^()]*(?:\([^()]*\)[^()]*)*)\)\s*(<|>=)\s*:(window_to|window_from)", + re.I | re.S, +) #: The column a subquery restricts: ``t.project_id IN ( WITH RECURSIVE …`` _IN_SUBQUERY = re.compile( r"(?:\w+\.)?(\w+)\s+IN\s*\(\s*WITH\s+RECURSIVE\s+(\w+)", re.I @@ -822,6 +833,26 @@ def _apply_columns( if re.search(r"\bAND\s+is_default\b", top, re.I): seen = True rows = [r for r in rows if r.get("is_default")] + for column in _NOW_LT.findall(top): + seen = True + rows = [ + r for r in rows + if r.get(column) is not None and _as_datetime(r[column]) < _now() + ] + # WS-27q's calendar window. Applied ONLY when the statement carries the + # bound, and each comparison is evaluated against the interval endpoint + # the SQL's own `coalesce` order names — so swapping that order, which + # is the mutation that turns "overlaps" back into "due inside", changes + # this mirror's answer instead of being invisible to it. + window = _WINDOW_CMP.findall(top) + if window: + seen = True + for expr, operator, bound in window: + edge = _as_datetime(args[bound]) + rows = [ + r for r in rows + if _compare_window(_coalesced(r, expr), operator, edge) + ] if re.search(r"\bTRUE\b", top, re.I): # The unrestricted (`data:org:read`) form of the visibility clause. # It is a readable clause that filters nothing, which is different @@ -861,6 +892,34 @@ def _as_datetime(value: Any) -> datetime: return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) +def _coalesced(row: dict, expression: str) -> datetime | None: + """What one ``coalesce(...)`` from the window clause evaluates to. + + The argument ORDER is read off the SQL rather than assumed, because that + order is the whole rule: `coalesce(start_date, due_at)` is the interval's + start and `coalesce(due_at, start_date)` is its end, and a mirror that + hard-coded either would agree with a route that swapped them. + """ + for part in expression.split(","): + column = "start_date" if "start_date" in part else "due_at" + value = row.get(column) + if value is not None: + return _as_datetime(value) + return None + + +def _compare_window(value: datetime | None, operator: str, edge: datetime) -> bool: + """One window comparison, with SQL's NULL semantics. + + A task with neither date has no interval, so both comparisons are NULL and + the row is not matched — the behaviour the endpoint relies on to keep + undated tasks off the calendar without a clause anybody can see. + """ + if value is None: + return False + return value < edge if operator == "<" else value >= edge + + def _sortable(value: Any) -> Any: """A total order across the mixed types one column can hold in a fake.""" if value is None: diff --git a/tests/unit/test_projects_calendar.py b/tests/unit/test_projects_calendar.py new file mode 100644 index 00000000..6306d549 --- /dev/null +++ b/tests/unit/test_projects_calendar.py @@ -0,0 +1,524 @@ +"""WS-27q — the calendar window. + +Spec: ``ai-company-brain/specs/project_management_app.md`` §11.2 item 9, §11.16. + +The third view, and the first one that cannot be a page. The claims worth +pinning are the ones where the wrong implementation looks right: + +* **a window is not a page.** Paginating a month draws forty of its ninety + tasks and leaves the other days looking EMPTY. A short page announces itself; + a short month does not — so the cap is explicit and the response says when it + was reached. +* **overlap, not equality.** A task that starts Monday and is due Friday belongs + on Wednesday. `due_at BETWEEN` — the obvious version — puts it on Friday + alone, which is the week somebody looks at Wednesday and thinks they are free. +* **a task with neither date is not on the calendar, and is COUNTED.** It falls + out of the overlap test on its own, through NULL rather than through any + clause a reader can see, so the behaviour is pinned rather than trusted. +* **the calendar honours the board's filters.** Switching view must change the + shape of what is on screen and never the set. +* **the window edge is half-open.** Two consecutive months must tile, not + overlap on the first. +""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import activities as pm_activities +from gateway.routes.projects import admin as pm_admin +from gateway.routes.projects import calendar as pm_calendar +from gateway.routes.projects import core as pm_core +from gateway.routes.projects import me as pm_me +from gateway.routes.projects import tasks as pm_tasks +from gateway.routes.projects import tree as pm_tree +from gateway.routes.projects import views as pm_views +from gateway.routes.projects.calendar import ( + MAX_WINDOW_DAYS, + MAX_WINDOW_ROWS, + OVERLAPS, + UNDATED, + parse_day, + window_bounds, +) + +from tests.unit._projects_fakes import ( + FakeProjectsDB, + bind_db, + member_user, + projects_user, + silence_events, +) + +MODULES = ( + pm_core, pm_tree, pm_tasks, pm_activities, pm_admin, pm_views, pm_me, + pm_calendar, +) +USER = projects_user() +#: Holds `feature:projects` but NOT `data:org:read`, so the grant closure +#: actually decides what they see. Without this principal a scoping test proves +#: only that the owner can see everything, which is true of an unscoped route. +MEMBER = member_user("colleague@fracktal.in") + +SOURCE = Path("apps/services/gateway/gateway/routes/projects/calendar.py") + + +@pytest.fixture +def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB: + fake = FakeProjectsDB() + bind_db(monkeypatch, fake, MODULES) + return fake + + +@pytest.fixture +def events(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, dict]]: + return silence_events(monkeypatch, MODULES) + + +# ── parse_day ─────────────────────────────────────────────────────────────── + +def test_a_calendar_date_is_read_as_a_date() -> None: + assert parse_day("2026-08-01", field="from").isoformat() == "2026-08-01" + + +@pytest.mark.parametrize("raw", ["august", "2026-13-01", "", "next week", "//"]) +def test_an_unreadable_window_edge_is_a_422_and_shows_the_format(raw: str) -> None: + """The client's mistake, told to the client. `from=august` answering 500 + reads as a server fault and gets reported as one.""" + with pytest.raises(HTTPException) as caught: + parse_day(raw, field="from") + assert caught.value.status_code == 422 + assert "2026-08-01" in caught.value.detail + + +def test_surrounding_whitespace_is_not_a_typo_worth_refusing() -> None: + assert parse_day(" 2026-08-01 ", field="from").isoformat() == "2026-08-01" + + +# ── window_bounds ─────────────────────────────────────────────────────────── + +def test_the_window_is_midnight_to_midnight_in_UTC() -> None: + start, end = window_bounds("2026-08-01", "2026-09-01") + assert start == datetime(2026, 8, 1, tzinfo=UTC) + assert end == datetime(2026, 9, 1, tzinfo=UTC) + + +def test_two_consecutive_months_tile_rather_than_overlap() -> None: + """⚠️ Half-open, and this is why: an inclusive end would put every task due + on the 31st in both August and September, and a calendar that disagrees + with itself across a page turn is worse than one that is conservative.""" + _, august_end = window_bounds("2026-08-01", "2026-09-01") + september_start, _ = window_bounds("2026-09-01", "2026-10-01") + assert august_end == september_start + + +def test_a_backwards_window_is_refused_rather_than_swapped() -> None: + """Swapping it would be helpful and wrong: the caller has a bug, and a + calendar that silently shows a different month than the one requested is + the hardest possible version of it to find.""" + with pytest.raises(HTTPException) as caught: + window_bounds("2026-09-01", "2026-08-01") + assert caught.value.status_code == 422 + + +def test_a_zero_length_window_is_refused() -> None: + with pytest.raises(HTTPException) as caught: + window_bounds("2026-08-01", "2026-08-01") + assert caught.value.status_code == 422 + + +def test_a_window_wider_than_the_maximum_is_refused_not_clamped() -> None: + """⚠️ Clamped, a client asking for five years would draw four empty ones and + conclude the workspace was empty in 2028.""" + with pytest.raises(HTTPException) as caught: + window_bounds("2026-01-01", "2030-01-01") + assert caught.value.status_code == 422 + assert str(MAX_WINDOW_DAYS) in caught.value.detail + + +def test_a_year_still_fits() -> None: + start, end = window_bounds("2026-01-01", "2027-01-01") + assert (end - start).days == 365 + + +def test_the_maximum_window_is_exactly_allowed() -> None: + # The boundary itself, so an off-by-one in the comparison shows up. + start, end = window_bounds("2026-01-01", "2027-02-05") + assert (end - start).days == MAX_WINDOW_DAYS + + +# ── The overlap rule, structurally ────────────────────────────────────────── + +def test_overlap_uses_the_interval_not_the_due_date_alone() -> None: + """⚠️ The claim the whole view rests on. `due_at BETWEEN :from AND :to` is + the implementation everyone writes first, and it hides every task whose + span crosses the window without ending in it.""" + assert "coalesce(t.due_at" in OVERLAPS + assert "coalesce(CAST(t.start_date AS timestamp)" in OVERLAPS + assert "BETWEEN" not in OVERLAPS + + +def test_the_start_date_is_anchored_to_UTC_not_to_the_session() -> None: + """⚠️ `CAST(start_date AS timestamptz)` would compile and would silently + read the connection's `TimeZone` — a session setting no caller controls, + no test would notice changing, and which shifts every bar by hours.""" + assert "AT TIME ZONE 'UTC'" in OVERLAPS + assert "CAST(t.start_date AS timestamptz)" not in OVERLAPS + + +def test_the_window_bounds_are_bound_parameters_not_interpolated() -> None: + assert ":window_from" in OVERLAPS + assert ":window_to" in OVERLAPS + + +def test_the_undated_clause_needs_both_dates_absent() -> None: + """A task with only a start date IS on the calendar. Counting it as + unscheduled would double-report it — drawn on the grid and tallied as + missing from it.""" + assert UNDATED == "t.start_date IS NULL AND t.due_at IS NULL" + + +def test_the_query_reads_everything_in_the_window_rather_than_a_page() -> None: + """⚠️ The reason this is a new endpoint at all. `OFFSET` here would be a + calendar with silently missing days.""" + source = SOURCE.read_text(encoding="utf-8") + body = source[source.index("async def get_calendar"):] + assert "OFFSET" not in body + assert ":cap" in body + + +def test_the_cap_is_probed_by_one_so_truncation_can_be_detected() -> None: + """Reading exactly `MAX_WINDOW_ROWS` cannot tell "full" from "overflowing", + so the query asks for one more than it will return.""" + source = SOURCE.read_text(encoding="utf-8") + assert "MAX_WINDOW_ROWS + 1" in source + + +#: The list endpoint's parameters the calendar deliberately does NOT take, and +#: why. Anything else the list grows must be added to the calendar too, which +#: is what the next test enforces. +NOT_ON_THE_CALENDAR = { + # Duplicates the window: two ways to bound the same column, where the + # losing one vanishes without a word. + "due_before", + # The window IS the shape; a calendar has no pages, no sort key and no + # parent-task drill-down. + "page", "sort", "direction", "parent_task_id", +} + + +def test_every_list_filter_is_accepted_by_the_calendar_or_named_as_excluded() -> None: + """⚠️ The silent-drop trap, and the reason this test reads both signatures. + + FastAPI ignores an unknown query parameter. So a filter the board sends and + the calendar does not declare is not an error — it is a filter that quietly + stops applying when you switch view, which reads as the FILTER being broken + rather than the calendar. The exclusions are listed above with a reason + each; a new list filter that is neither accepted nor listed fails here. + """ + from gateway.routes.projects import router + + def params(path: str) -> set[str]: + route = next(r for r in router.routes if r.path == path) + return {p.name for p in route.dependant.query_params} + + listed = params("/projects/tasks") + calendared = params("/projects/calendar") + missing = listed - calendared - NOT_ON_THE_CALENDAR + assert missing == set(), ( + f"the list accepts {sorted(missing)} and the calendar does not — " + f"FastAPI will drop them silently" + ) + + +def test_the_window_is_not_also_a_due_date_filter() -> None: + """`due_before` stays out: bounding the same column twice is how the two + bounds come to disagree, and the loser leaves no trace.""" + source = SOURCE.read_text(encoding="utf-8") + start = source.index("async def get_calendar") + signature = source[start:source.index(") -> dict:", start)] + assert "due_before" not in signature + + +@pytest.mark.asyncio +async def test_an_overdue_filter_survives_the_switch_to_calendar(db, events): + """⚠️ `overdue` looks like `due_before`'s twin and is not: "already late" + is a fact about the status as much as the date. Dropped, a board filtered + to overdue work would show everything the moment somebody switched view.""" + project, todo, _ = _workspace(db) + db.seed_task(project.id, todo.id, title="Old", due_at="2020-01-10T10:00:00Z") + db.seed_task(project.id, todo.id, title="Later", due_at="2030-01-10T10:00:00Z") + + async def month(year: str, **kwargs): + result = await pm_calendar.get_calendar( + user=USER, date_from=f"{year}-01-01", date_to=f"{year}-02-01", **kwargs, + ) + return [r["title"] for r in result["rows"]] + + # A window either side of any plausible "now", so the assertion is about + # the clause rather than about what today happens to be. + assert await month("2020") == ["Old"] + assert await month("2020", overdue=True) == ["Old"] + assert await month("2030") == ["Later"] + assert await month("2030", overdue=True) == [] + + +# ── Behaviour ─────────────────────────────────────────────────────────────── + +def _workspace(db: FakeProjectsDB) -> tuple: + project = db.seed_project(name="Ops", subject="owner@fracktal.in") + todo = db.seed_status(project.id, name="To do", category="todo", is_default=True) + done = db.seed_status( + project.id, name="Done", category="done", is_default=False, position=40, + ) + return project, todo, done + + +async def _window(**kwargs): + return await pm_calendar.get_calendar( + user=USER, date_from="2026-08-01", date_to="2026-09-01", **kwargs, + ) + + +@pytest.mark.asyncio +async def test_a_task_due_inside_the_window_is_returned(db, events): + project, todo, _ = _workspace(db) + db.seed_task(project.id, todo.id, title="Ship it", due_at="2026-08-14T10:00:00Z") + + result = await _window() + + assert [r["title"] for r in result["rows"]] == ["Ship it"] + assert result["from"] == "2026-08-01" + assert result["to"] == "2026-09-01" + + +@pytest.mark.asyncio +async def test_a_task_with_only_a_start_date_is_on_the_calendar(db, events): + """`start_date` has existed since migration 146 and no surface has shown + it. A calendar that ignored it would leave the column exactly as + unreachable as it was.""" + project, todo, _ = _workspace(db) + db.seed_task(project.id, todo.id, title="Kickoff", start_date="2026-08-03") + + assert [r["title"] for r in (await _window())["rows"]] == ["Kickoff"] + + +@pytest.mark.asyncio +async def test_a_task_with_neither_date_is_absent_and_counted(db, events): + """⚠️ It falls out through NULL rather than through any clause a reader can + see. Dropping it silently is how a calendar comes to look like the whole + workspace while showing a third of it.""" + project, todo, _ = _workspace(db) + db.seed_task(project.id, todo.id, title="Someday") + db.seed_task(project.id, todo.id, title="Dated", due_at="2026-08-14T10:00:00Z") + + result = await _window() + + assert [r["title"] for r in result["rows"]] == ["Dated"] + assert result["undated"] == 1 + + +@pytest.mark.asyncio +async def test_a_task_ending_before_the_window_is_absent(db, events): + project, todo, _ = _workspace(db) + db.seed_task(project.id, todo.id, title="Last month", due_at="2026-07-20T10:00:00Z") + + assert (await _window())["rows"] == [] + + +@pytest.mark.asyncio +async def test_a_task_starting_after_the_window_is_absent(db, events): + project, todo, _ = _workspace(db) + db.seed_task(project.id, todo.id, title="Next month", start_date="2026-09-10") + + assert (await _window())["rows"] == [] + + +@pytest.mark.asyncio +async def test_a_task_spanning_the_whole_window_is_present(db, events): + """⚠️ The case `due_at BETWEEN` gets wrong: neither end is inside the + window, and the task covers every day of it.""" + project, todo, _ = _workspace(db) + db.seed_task( + project.id, todo.id, title="The quarter", + start_date="2026-06-01", due_at="2026-12-01T00:00:00Z", + ) + + assert [r["title"] for r in (await _window())["rows"]] == ["The quarter"] + + +@pytest.mark.asyncio +async def test_the_windows_last_day_is_excluded(db, events): + """Half-open: a task due at the September boundary belongs to September.""" + project, todo, _ = _workspace(db) + db.seed_task(project.id, todo.id, title="September", due_at="2026-09-01T00:00:00Z") + + assert (await _window())["rows"] == [] + + +@pytest.mark.asyncio +async def test_the_calendar_honours_the_boards_filters(db, events): + """⚠️ Switching view must change the SHAPE of what is on screen, never the + set. A calendar that ignored the filter would read as the filter breaking.""" + project, todo, done = _workspace(db) + db.seed_task(project.id, todo.id, title="Open", due_at="2026-08-10T10:00:00Z") + db.seed_task(project.id, done.id, title="Closed", due_at="2026-08-11T10:00:00Z") + + result = await _window(status_category="todo") + + assert [r["title"] for r in result["rows"]] == ["Open"] + + +@pytest.mark.asyncio +async def test_the_unscheduled_count_uses_the_same_filters(db, events): + """⚠️ Counted without them, "12 unscheduled" would mean twelve somewhere in + the workspace rather than twelve of the tasks being looked at — a number + that never matches what clicking it shows.""" + project, todo, done = _workspace(db) + db.seed_task(project.id, todo.id, title="Open and undated") + db.seed_task(project.id, done.id, title="Closed and undated") + + assert (await _window(status_category="todo"))["undated"] == 1 + + +@pytest.mark.asyncio +async def test_an_archived_task_is_off_the_calendar_by_default(db, events): + project, todo, _ = _workspace(db) + db.seed_task( + project.id, todo.id, title="Dropped", due_at="2026-08-10T10:00:00Z", + archived_at="2026-08-01T00:00:00Z", + ) + + assert (await _window())["rows"] == [] + + +@pytest.mark.asyncio +async def test_a_calendar_chip_carries_the_shared_card_badges(db, events): + """WS-27s' vocabulary, on the third view too: a chip that could not draw a + blocked flag would be a fourth way for a task to look different.""" + project, todo, _ = _workspace(db) + task = db.seed_task( + project.id, todo.id, title="Ship it", due_at="2026-08-14T10:00:00Z", + ) + + row = (await _window())["rows"][0] + + assert row["subtasks"] == {"done": 0, "total": 0} + assert row["blocked_by_count"] == 0 + assert "assignees" in row + assert str(task.id) == row["id"] + + +@pytest.mark.asyncio +async def test_an_unreadable_project_is_a_404_not_an_empty_calendar(db, events): + """R5. An empty calendar would confirm the project exists and is quiet.""" + _workspace(db) + hidden = db.seed_project(name="Secret", subject=None) + + with pytest.raises(HTTPException) as caught: + await pm_calendar.get_calendar( + user=MEMBER, date_from="2026-08-01", date_to="2026-09-01", + project_id=str(hidden.id), + ) + assert caught.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_a_bad_window_is_refused_before_the_database_is_touched(db, events): + """The 422 comes from a pure function, so a malformed window costs a + connection rather than a query — and cannot half-run.""" + with pytest.raises(HTTPException) as caught: + await pm_calendar.get_calendar( + user=USER, date_from="not-a-date", date_to="2026-09-01", + ) + assert caught.value.status_code == 422 + assert db.statements == [] + + +@pytest.mark.asyncio +async def test_an_overflowing_window_says_so_and_returns_exactly_the_cap( + db, events, monkeypatch: pytest.MonkeyPatch, +): + """⚠️ The failure this endpoint exists to prevent. A calendar quietly + missing a third of its tasks looks exactly like a calendar with fewer + tasks, and nobody investigates a quiet week. + + The cap is lowered rather than the fixture raised: seeding a thousand tasks + to assert a boolean is a slow test that proves the same thing. + """ + monkeypatch.setattr(pm_calendar, "MAX_WINDOW_ROWS", 2) + project, todo, _ = _workspace(db) + for n in range(5): + db.seed_task( + project.id, todo.id, title=f"Task {n}", + due_at=f"2026-08-1{n}T10:00:00Z", + ) + + result = await _window() + + assert result["truncated"] is True + # The probe row must not leak into the answer: returning cap + 1 would put + # a task on the calendar that the "showing 2 of many" notice says is not. + assert len(result["rows"]) == 2 + assert result["cap"] == 2 + + +@pytest.mark.asyncio +async def test_a_window_exactly_at_the_cap_is_not_truncated(db, events, monkeypatch): + """The off-by-one: `>=` here would report every full window as short.""" + monkeypatch.setattr(pm_calendar, "MAX_WINDOW_ROWS", 2) + project, todo, _ = _workspace(db) + for n in range(2): + db.seed_task( + project.id, todo.id, title=f"Task {n}", + due_at=f"2026-08-1{n}T10:00:00Z", + ) + + result = await _window() + + assert result["truncated"] is False + assert len(result["rows"]) == 2 + + +@pytest.mark.asyncio +async def test_a_full_window_is_not_reported_as_truncated(db, events): + project, todo, _ = _workspace(db) + db.seed_task(project.id, todo.id, title="One", due_at="2026-08-10T10:00:00Z") + + result = await _window() + + assert result["truncated"] is False + assert result["cap"] == MAX_WINDOW_ROWS + + +# ── Wiring ────────────────────────────────────────────────────────────────── + +def test_the_calendar_route_is_actually_mounted() -> None: + """⚠️ A module left out of ``__init__.py`` mounts nothing while every test + that calls its function directly still passes.""" + from gateway.routes.projects import router + + assert "/projects/calendar" in {route.path for route in router.routes} + + +def test_the_wire_names_are_from_and_to_not_the_python_names() -> None: + """`from` is a Python keyword, so the alias is the only thing standing + between the documented API and `?date_from=`.""" + from gateway.routes.projects import router + + route = next(r for r in router.routes if r.path == "/projects/calendar") + names = {p.alias for p in route.dependant.query_params} + assert {"from", "to"} <= names + + +def test_no_second_way_to_move_a_task_was_added() -> None: + """Dragging a card is a `PATCH /tasks/{id}` — the same validation, the same + `field_change` activity, the same revert. A `POST /calendar/move` would be + a second write path, which is how two paths start disagreeing about what is + allowed.""" + source = SOURCE.read_text(encoding="utf-8") + assert not re.search(r"@router\.(post|patch|put|delete)", source) diff --git a/workbench/control_plane/src/app/projects/components/CalendarView.tsx b/workbench/control_plane/src/app/projects/components/CalendarView.tsx new file mode 100644 index 00000000..6a6e5bac --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/CalendarView.tsx @@ -0,0 +1,163 @@ +"use client"; + +/** + * Projects · the month calendar (WS-27q). + * + * The third view, after list and board. All of the arithmetic — which days the + * grid covers, which cells a task occupies, what a drop should write — is in + * `lib/calendar.ts` and tested there; this file only draws it and wires the + * gestures, because a calendar bug is a task on the wrong Tuesday and that is + * not something a component test would catch either. + * + * **Two honest admissions on the surface, both deliberate.** A calendar that + * silently omits tasks is worse than one that looks incomplete: `truncated` + * says when the window hit its cap, and `undated` says how many tasks have no + * dates at all and therefore cannot be here. Without those two the view reads + * as the whole workspace while showing part of it. + */ + +import { TaskMeta } from "@/components/TaskMeta"; +import Button from "@/components/ui/Button"; + +import type { TaskRow } from "../lib/api"; +import { + type MonthGrid, + isOutsideMonth, + monthLabel, + placeTasks, + rescheduleTo, +} from "../lib/calendar"; +import { cardChips } from "../lib/card"; + +const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +interface Props { + grid: MonthGrid; + tasks: TaskRow[]; + /** How many matching tasks have no dates and so cannot be drawn. */ + undated: number; + /** The window hit the server's cap; some tasks are missing. */ + truncated: boolean; + today?: string; + onSelect: (task: TaskRow) => void; + onMove: (task: TaskRow, patch: Record) => void; + onStep: (months: number) => void; + onToday: () => void; +} + +export function CalendarView({ + grid, + tasks, + undated, + truncated, + today, + onSelect, + onMove, + onStep, + onToday, +}: Props) { + const byDay = placeTasks(tasks, grid); + + return ( +
+
+ +

{monthLabel(grid)}

+ + {undated > 0 ? ( + + {undated} unscheduled + + ) : null} + {truncated ? ( + + Too many tasks in this month to show them all — narrow the filters. + + ) : null} + +
+ +
+ {WEEKDAYS.map((label) => ( +
+ {label} +
+ ))} + {grid.days.map((day) => { + const outside = isOutsideMonth(day, grid); + return ( +
e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const id = e.dataTransfer.getData("text/plain"); + const task = tasks.find((t) => t.id === id); + if (!task) return; + // `rescheduleTo` returns null for a drop that changes nothing, + // so a task dropped back on its own day writes nothing rather + // than posting an activity saying it moved to where it was. + const patch = rescheduleTo(task, day); + if (patch) onMove(task, patch as Record); + }} + className={`min-h-24 bg-card p-1 ${outside ? "opacity-50" : ""}`} + > +
+ + {Number(day.slice(8))} + +
+
    + {(byDay.get(day) ?? []).map((task) => ( +
  • + +
  • + ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts index 6f6ca61c..85bc528b 100644 --- a/workbench/control_plane/src/app/projects/lib/api.ts +++ b/workbench/control_plane/src/app/projects/lib/api.ts @@ -30,6 +30,14 @@ export interface TaskRow { title: string; description?: string | null; importance?: number | null; + estimate_mins?: number | null; + /** + * WS-27q — a floating calendar date (`DATE`, not an instant), which is why + * it is never routed through `new Date()`: that would read it as midnight + * UTC and move it a day west of Greenwich. A column that has existed since + * migration 146 and had no surface until the calendar. + */ + start_date?: string | null; due_at?: string | null; completed_at?: string | null; tags?: string[]; @@ -182,6 +190,30 @@ export const projectsApi = { return call<{ rows: TaskRow[]; total: number }>(`tasks?${qs.toString()}`); }, + /** + * WS-27q — every task whose schedule overlaps a window. + * + * Deliberately NOT `tasks` with a date filter: that endpoint is paginated, + * and a month read at `page_size=50` draws forty of its ninety tasks and + * leaves the rest of the days looking empty. `truncated` is the endpoint + * telling us when the cap was reached, so the view can say so rather than + * present a plausible-looking short month. + */ + calendar: (params: Record) => { + const qs = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== "") qs.set(key, String(value)); + } + return call<{ + from: string; + to: string; + rows: TaskRow[]; + truncated: boolean; + cap: number; + undated: number; + }>(`calendar?${qs.toString()}`); + }, + task: (taskId: string) => call(`tasks/${taskId}`), timeline: (taskId: string) => diff --git a/workbench/control_plane/src/app/projects/lib/calendar.test.ts b/workbench/control_plane/src/app/projects/lib/calendar.test.ts new file mode 100644 index 00000000..87f58699 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/calendar.test.ts @@ -0,0 +1,364 @@ +/** + * WS-27q — the calendar grid, as arithmetic. + * + * A calendar bug is almost never a crash. It is a task on the wrong Tuesday, + * which looks completely normal, so every claim here is one that a plausible + * implementation gets wrong silently: + * + * * **`new Date("2026-08-07")` is midnight UTC**, which is the 6th anywhere + * west of Greenwich. Routing a `start_date` through it is the single most + * common way a calendar loses a day, and nothing about the result looks + * broken. + * * **a bar occupies every day it covers.** A task running Monday to Friday + * that appears only on Friday is exactly how somebody looks at Wednesday and + * concludes they are free. + * * **dragging a bar moves the whole bar.** Writing only the dropped date + * leaves the other end behind, and inverts the interval the moment you drag + * left — a data corruption that reads as a rendering glitch. + * * **the requested window carries a day of slack.** Without it a viewer in + * UTC+5:30 loses every task due in the first 5½ hours of the grid. + * + * These run with the process timezone as configured for vitest; the assertions + * are written so they hold in any of them — day keys are compared to day keys, + * never to a formatted instant. + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import type { TaskRow } from "./api"; +import { + calendarWindow, + dayKey, + fromDayKey, + isOutsideMonth, + monthGrid, + monthLabel, + placeTasks, + rescheduleTo, + shiftDay, + shiftMonth, + taskDays, +} from "./calendar"; + +const AUGUST = monthGrid(new Date(2026, 7, 1)); + +const task = (over: Partial = {}): TaskRow => ({ + id: "t1", + project_id: "p1", + root_project_id: "p1", + status_id: "s1", + title: "Ship it", + ...over, +}); + +/** A local-midnight instant for a day key, so `due_at` fixtures are timezone + * independent — the same trap the module itself avoids. */ +const localNoon = (key: string) => { + const d = fromDayKey(key); + d.setHours(12, 0, 0, 0); + return d.toISOString(); +}; + +// ── day keys ──────────────────────────────────────────────────────────────── + +describe("dayKey / fromDayKey", () => { + it("round-trips a date through its key", () => { + expect(dayKey(fromDayKey("2026-08-07"))).toBe("2026-08-07"); + }); + + it("reads the LOCAL day, not the UTC one", () => { + // ⚠️ `toISOString().slice(0,10)` is the wrong implementation and passes in + // UTC. A local-midnight Date must key as its own day in every timezone. + const midnight = new Date(2026, 7, 7, 0, 0, 0); + expect(dayKey(midnight)).toBe("2026-08-07"); + const almostMidnight = new Date(2026, 7, 7, 23, 59, 0); + expect(dayKey(almostMidnight)).toBe("2026-08-07"); + }); + + it("pads single-digit months and days", () => { + expect(dayKey(new Date(2026, 0, 5))).toBe("2026-01-05"); + }); +}); + +describe("shiftDay", () => { + it("rolls over a month boundary", () => { + expect(shiftDay("2026-08-31", 1)).toBe("2026-09-01"); + expect(shiftDay("2026-09-01", -1)).toBe("2026-08-31"); + }); + + it("rolls over a year boundary", () => { + expect(shiftDay("2026-12-31", 1)).toBe("2027-01-01"); + }); + + it("handles a leap day", () => { + expect(shiftDay("2028-02-28", 1)).toBe("2028-02-29"); + expect(shiftDay("2026-02-28", 1)).toBe("2026-03-01"); + }); +}); + +// ── the grid ──────────────────────────────────────────────────────────────── + +describe("monthGrid", () => { + it("covers the whole month", () => { + expect(AUGUST.days).toContain("2026-08-01"); + expect(AUGUST.days).toContain("2026-08-31"); + expect(AUGUST.month).toBe("2026-08"); + }); + + it("starts on a Monday and ends on a Sunday", () => { + // ⚠️ A grid whose weekend is split across two rows makes "what is left + // this week" something you count instead of see. + expect(fromDayKey(AUGUST.days[0]).getDay()).toBe(1); + expect(fromDayKey(AUGUST.days[AUGUST.days.length - 1]).getDay()).toBe(0); + }); + + it("is always whole weeks of seven", () => { + expect(AUGUST.days.length % 7).toBe(0); + expect(AUGUST.weeks.every((w) => w.length === 7)).toBe(true); + expect(AUGUST.weeks.flat()).toEqual(AUGUST.days); + }); + + it("pads to the week boundary rather than to a fixed six rows", () => { + // ⚠️ A fixed six rows shows an entire extra week of March for a February + // that starts on a Monday. February 2027 starts on a Monday and has 28 + // days: exactly four weeks, and no padding at all is correct. + const february = monthGrid(new Date(2027, 1, 1)); + expect(february.weeks).toHaveLength(4); + expect(february.days[0]).toBe("2027-02-01"); + expect(february.days[february.days.length - 1]).toBe("2027-02-28"); + }); + + it("pads a month that starts on a Sunday from the Monday before", () => { + // The worst case for a Monday week: one leading day short of a full week. + const november = monthGrid(new Date(2026, 10, 1)); + expect(november.days[0]).toBe("2026-10-26"); + expect(november.days).toContain("2026-11-01"); + }); + + it("labels itself in words", () => { + expect(monthLabel(AUGUST)).toBe("August 2026"); + expect(monthLabel(monthGrid(new Date(2026, 0, 1)))).toBe("January 2026"); + expect(monthLabel(monthGrid(new Date(2026, 11, 1)))).toBe("December 2026"); + }); + + it("knows which of its days belong to a neighbour", () => { + expect(isOutsideMonth("2026-07-31", AUGUST)).toBe(true); + expect(isOutsideMonth("2026-08-01", AUGUST)).toBe(false); + }); +}); + +describe("shiftMonth", () => { + it("steps a month at a time without landing on the 31st of February", () => { + // ⚠️ `setMonth(+1)` on the 31st gives March 3rd. Anchoring on the 1st is + // what keeps "next month" from skipping one. + const january31 = monthGrid(new Date(2026, 0, 31)); + expect(monthGrid(shiftMonth(january31, 1)).month).toBe("2026-02"); + }); + + it("steps across a year in both directions", () => { + expect(monthGrid(shiftMonth(monthGrid(new Date(2026, 11, 1)), 1)).month) + .toBe("2027-01"); + expect(monthGrid(shiftMonth(monthGrid(new Date(2026, 0, 1)), -1)).month) + .toBe("2025-12"); + }); +}); + +// ── the requested window ──────────────────────────────────────────────────── + +describe("calendarWindow", () => { + it("asks for a day of slack on each side", () => { + // ⚠️ The contract with the server, not defensive padding: the endpoint + // reads the window in UTC and over-selects, and the browser places. Drop + // the slack and a viewer at UTC+5:30 loses the grid's first morning. + const { from, to } = calendarWindow(AUGUST); + expect(from).toBe(shiftDay(AUGUST.days[0], -1)); + expect(to).toBe(shiftDay(AUGUST.days[AUGUST.days.length - 1], 2)); + }); + + it("names an exclusive end past the last drawn day", () => { + // Half-open, matching the endpoint: the last grid day must be strictly + // inside the window, or its tasks never arrive. + const { to } = calendarWindow(AUGUST); + expect(to > AUGUST.days[AUGUST.days.length - 1]).toBe(true); + }); +}); + +// ── placement ─────────────────────────────────────────────────────────────── + +describe("taskDays", () => { + it("puts a due-date-only task on its own day", () => { + expect(taskDays(task({ due_at: localNoon("2026-08-14") }), AUGUST)) + .toEqual(["2026-08-14"]); + }); + + it("takes a start_date as written rather than through a Date", () => { + // ⚠️ THE timezone trap. `new Date("2026-08-03")` is midnight UTC, which is + // August 2nd in every timezone west of Greenwich. + expect(taskDays(task({ start_date: "2026-08-03" }), AUGUST)) + .toEqual(["2026-08-03"]); + }); + + it("never routes a start_date through the Date constructor", () => { + // ⚠️ Structural, because the behavioural version of this claim only fails + // WEST of Greenwich: in UTC — and everywhere east of it — `new Date( + // "2026-08-03")` happens to key as the 3rd anyway, so the test above + // passes with the bug in place. CI runs in one timezone, so without this + // the mutation that reintroduces the trap survives forever. + const source = readFileSync( + fileURLToPath(new URL("./calendar.ts", import.meta.url)), + "utf8", + ); + expect(source).not.toMatch(/new Date\(\s*(task\.)?start_?[Dd]ate/); + expect(source).not.toMatch(/new Date\(\s*startKey/); + }); + + it("spans every day between start and due", () => { + // ⚠️ The reason the endpoint filters on overlap. A Monday-to-Friday task + // that shows only on Friday is how somebody looks at Wednesday and + // concludes they are free. + expect( + taskDays( + task({ start_date: "2026-08-10", due_at: localNoon("2026-08-14") }), + AUGUST, + ), + ).toEqual([ + "2026-08-10", "2026-08-11", "2026-08-12", "2026-08-13", "2026-08-14", + ]); + }); + + it("clamps a bar that runs past both edges of the grid", () => { + const days = taskDays( + task({ start_date: "2026-06-01", due_at: localNoon("2026-12-01") }), + AUGUST, + ); + expect(days).toEqual(AUGUST.days); + }); + + it("places nothing for a task with no dates", () => { + // ⚠️ Falling back to today would put a task on a day nobody scheduled it + // for, which is worse than its absence — the view counts those separately. + expect(taskDays(task(), AUGUST)).toEqual([]); + }); + + it("places nothing for a bar entirely outside the grid", () => { + expect( + taskDays( + task({ start_date: "2026-01-01", due_at: localNoon("2026-01-05") }), + AUGUST, + ), + ).toEqual([]); + }); + + it("shows a backwards interval rather than swallowing the task", () => { + // Bad data — due before start. Rendering nothing would hide a task from + // the only view that would have shown the problem. + const days = taskDays( + task({ start_date: "2026-08-20", due_at: localNoon("2026-08-18") }), + AUGUST, + ); + expect(days[0]).toBe("2026-08-18"); + expect(days[days.length - 1]).toBe("2026-08-20"); + }); +}); + +describe("placeTasks", () => { + it("gives every grid day a bucket, empty or not", () => { + const byDay = placeTasks([], AUGUST); + expect([...byDay.keys()]).toEqual(AUGUST.days); + expect([...byDay.values()].every((v) => v.length === 0)).toBe(true); + }); + + it("puts a bar in every cell it covers, not only the first", () => { + const bar = task({ + id: "bar", start_date: "2026-08-10", due_at: localNoon("2026-08-12"), + }); + const byDay = placeTasks([bar], AUGUST); + expect(byDay.get("2026-08-10")).toHaveLength(1); + expect(byDay.get("2026-08-11")).toHaveLength(1); + expect(byDay.get("2026-08-12")).toHaveLength(1); + expect(byDay.get("2026-08-13")).toHaveLength(0); + }); + + it("drops a task the grid does not reach without losing the others", () => { + const near = task({ id: "near", due_at: localNoon("2026-08-05") }); + const far = task({ id: "far", due_at: localNoon("2027-01-05") }); + const byDay = placeTasks([near, far], AUGUST); + expect(byDay.get("2026-08-05")?.map((t) => t.id)).toEqual(["near"]); + expect([...byDay.values()].flat().map((t) => t.id)).toEqual(["near"]); + }); +}); + +// ── rescheduling ──────────────────────────────────────────────────────────── + +describe("rescheduleTo", () => { + it("moves a due-date-only task and keeps its time of day", () => { + // "Due Friday at 5" dragged to Monday is due Monday at 5. + const at5 = fromDayKey("2026-08-14"); + at5.setHours(17, 30, 0, 0); + const patch = rescheduleTo(task({ due_at: at5.toISOString() }), "2026-08-10"); + expect(patch).not.toBeNull(); + expect(patch?.start_date).toBeUndefined(); + const moved = new Date(patch?.due_at as string); + expect(dayKey(moved)).toBe("2026-08-10"); + expect([moved.getHours(), moved.getMinutes()]).toEqual([17, 30]); + }); + + it("moves a start-date-only task", () => { + expect(rescheduleTo(task({ start_date: "2026-08-03" }), "2026-08-06")) + .toEqual({ start_date: "2026-08-06" }); + }); + + it("moves the WHOLE bar and preserves its length", () => { + // ⚠️ The rule every calendar-drag gets wrong first. Writing only the + // dropped date leaves the other end behind and inverts the interval the + // moment you drag left. + const patch = rescheduleTo( + task({ start_date: "2026-08-10", due_at: localNoon("2026-08-14") }), + "2026-08-12", + ); + expect(patch?.start_date).toBe("2026-08-12"); + expect(dayKey(new Date(patch?.due_at as string))).toBe("2026-08-16"); + }); + + it("keeps the span when dragged backwards", () => { + const patch = rescheduleTo( + task({ start_date: "2026-08-10", due_at: localNoon("2026-08-14") }), + "2026-08-05", + ); + expect(patch?.start_date).toBe("2026-08-05"); + expect(dayKey(new Date(patch?.due_at as string))).toBe("2026-08-09"); + }); + + it("anchors on the START of a bar, so dropping on its own start is a no-op", () => { + expect( + rescheduleTo( + task({ start_date: "2026-08-10", due_at: localNoon("2026-08-14") }), + "2026-08-10", + ), + ).toBeNull(); + }); + + it("writes nothing when the task is already on that day", () => { + // ⚠️ An activity row saying a task moved from Tuesday to Tuesday is noise + // in the one place people go to find out what changed. + expect(rescheduleTo(task({ due_at: localNoon("2026-08-14") }), "2026-08-14")) + .toBeNull(); + }); + + it("refuses to schedule a task that has no dates at all", () => { + // Dropping an undated task on a day is a reasonable feature and a + // different one: it must decide which of the two dates it is setting, and + // guessing here would set whichever the implementation happened to prefer. + expect(rescheduleTo(task(), "2026-08-14")).toBeNull(); + }); + + it("crosses a month boundary in both directions", () => { + expect(rescheduleTo(task({ start_date: "2026-08-31" }), "2026-09-02")) + .toEqual({ start_date: "2026-09-02" }); + expect(rescheduleTo(task({ start_date: "2026-09-01" }), "2026-08-30")) + .toEqual({ start_date: "2026-08-30" }); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/calendar.ts b/workbench/control_plane/src/app/projects/lib/calendar.ts new file mode 100644 index 00000000..3ae127ac --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/calendar.ts @@ -0,0 +1,233 @@ +/** + * Projects · the calendar grid, as arithmetic (WS-27q). + * + * Every decision a month view makes that can be wrong without looking wrong — + * which days a grid covers, which cells a task occupies, what dragging it to a + * day should write — lives here as a pure function, because a calendar bug is + * almost never a crash. It is a task on the wrong Tuesday, and the only way to + * catch that is to assert on the arithmetic rather than to look at it. + * + * **Dates are handled as `YYYY-MM-DD` keys, not as `Date` objects, wherever a + * DAY is meant.** `new Date("2026-08-07")` is midnight UTC, which is the 6th in + * any western timezone — the single most common way a calendar loses a day. A + * `Date` is used only for the arithmetic of walking a month, always through + * local-time constructors and accessors, never through `toISOString()`. + */ + +import type { TaskRow } from "./api"; + +const DAY_MS = 86_400_000; + +/** `YYYY-MM-DD` for a Date, read in LOCAL time. */ +export function dayKey(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +/** A `YYYY-MM-DD` key back to a local-midnight Date. */ +export function fromDayKey(key: string): Date { + const [y, m, d] = key.split("-").map(Number); + return new Date(y, (m ?? 1) - 1, d ?? 1); +} + +/** `n` days after a key, as a key. Month and year roll over. */ +export function shiftDay(key: string, days: number): string { + const date = fromDayKey(key); + date.setDate(date.getDate() + days); + return dayKey(date); +} + +export interface MonthGrid { + /** The month the grid is *about*, as `YYYY-MM`. */ + month: string; + /** Every day drawn, in order — always whole weeks. */ + days: string[]; + /** Rows of seven. */ + weeks: string[][]; +} + +/** + * The days a month view draws, padded to whole weeks from Monday. + * + * **Monday, not Sunday.** The workspace this is for runs a Monday week, and a + * grid whose weekend is split across two rows makes "what is left this week" a + * question you have to count rather than see. + * + * The grid is padded to *whole weeks only* — not to a fixed six rows. A fixed + * six always shows days from two neighbouring months and, in a 28-day February + * starting on a Monday, an entire extra week of March. Padding to the week + * boundary is the smallest grid that is still rectangular. + */ +export function monthGrid(anchor: Date): MonthGrid { + const year = anchor.getFullYear(); + const month = anchor.getMonth(); + const first = new Date(year, month, 1); + const last = new Date(year, month + 1, 0); + + // getDay() is 0=Sunday; a Monday week wants Monday=0, so Sunday becomes 6. + const leading = (first.getDay() + 6) % 7; + const trailing = 6 - ((last.getDay() + 6) % 7); + + const start = new Date(year, month, 1 - leading); + const total = leading + last.getDate() + trailing; + + const days: string[] = []; + for (let i = 0; i < total; i += 1) { + days.push(dayKey(new Date(start.getFullYear(), start.getMonth(), start.getDate() + i))); + } + + const weeks: string[][] = []; + for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7)); + + return { + month: `${year}-${String(month + 1).padStart(2, "0")}`, + days, + weeks, + }; +} + +/** + * The window to ask the server for, with a day of slack on each side. + * + * **The slack is not defensive padding, it is the contract.** The server reads + * the window in UTC because a `start_date` is a floating date and a `due_at` is + * an instant, and no single frame makes both exact. So it OVER-selects and the + * browser — the only party that knows the viewer's timezone — does the + * placement. Without the extra day, a viewer in UTC+5:30 loses every task due + * in the first 5½ hours of the grid's first day. + * + * `to` is EXCLUSIVE, matching the endpoint's half-open window, so two + * consecutive months tile instead of both claiming the tasks on the boundary. + */ +export function calendarWindow(grid: MonthGrid): { from: string; to: string } { + const first = grid.days[0]; + const last = grid.days[grid.days.length - 1]; + return { from: shiftDay(first, -1), to: shiftDay(last, 2) }; +} + +/** + * The day keys a task occupies, clamped to the grid. + * + * A task with both dates is a BAR and belongs on every day it covers — the + * whole reason the endpoint filters on overlap. A task with one date is a + * single day. A task with neither is nowhere, and returns an empty list rather + * than being placed on today, which would be a task appearing on a day nobody + * scheduled it for. + * + * Clamping matters as much as the span: a task running June to December must + * occupy all of August's cells and none outside them, and an unclamped range + * would try to render 180 cells that do not exist. + */ +export function taskDays(task: TaskRow, grid: MonthGrid): string[] { + const startKey = task.start_date ? task.start_date.slice(0, 10) : null; + // `due_at` is an instant, so it is read in the VIEWER's timezone — that is + // the day they would say it is due. `start_date` is a floating date and is + // taken as written; converting it through a Date would move it. + const dueKey = task.due_at ? dayKey(new Date(task.due_at)) : null; + if (!startKey && !dueKey) return []; + + const from = startKey ?? (dueKey as string); + const to = dueKey ?? (startKey as string); + // A due date before the start date is bad data, not a reason to render + // nothing: show it on both endpoints rather than swallowing the task. + const lo = from <= to ? from : to; + const hi = from <= to ? to : from; + + const gridFrom = grid.days[0]; + const gridTo = grid.days[grid.days.length - 1]; + const first = lo > gridFrom ? lo : gridFrom; + const lastDay = hi < gridTo ? hi : gridTo; + if (first > lastDay) return []; + + const out: string[] = []; + const span = Math.round( + (fromDayKey(lastDay).getTime() - fromDayKey(first).getTime()) / DAY_MS, + ); + for (let i = 0; i <= span; i += 1) out.push(shiftDay(first, i)); + return out; +} + +/** Tasks bucketed by day key. Every grid day gets an entry, empty or not. */ +export function placeTasks( + tasks: readonly TaskRow[], + grid: MonthGrid, +): Map { + const byDay = new Map(grid.days.map((d) => [d, []])); + for (const task of tasks) { + for (const day of taskDays(task, grid)) byDay.get(day)?.push(task); + } + return byDay; +} + +/** + * The PATCH that moves a task to a day, or `null` if it is already there. + * + * **Dragging a bar moves the whole bar.** A task starting Monday and due Friday + * dropped on Wednesday runs Wednesday to Sunday — the span is the estimate + * somebody made, and a drag that silently shortens it to a single day destroys + * information the user did not offer to change. This is the rule every + * calendar-drag implementation gets wrong first, by writing only the date the + * card was dropped on and leaving the other end where it was, which inverts + * the interval as soon as you drag left. + * + * The `due_at` TIME OF DAY is preserved for the same reason: "due Friday at 5" + * dragged to Monday is due Monday at 5, not Monday at midnight. + * + * Returns `null` for a no-op so a drop that did not move anything writes + * nothing — an activity row saying a task moved from Tuesday to Tuesday is + * noise in the one place people go to find out what changed. + */ +export function rescheduleTo( + task: TaskRow, + day: string, +): { start_date?: string | null; due_at?: string | null } | null { + const startKey = task.start_date ? task.start_date.slice(0, 10) : null; + const dueDate = task.due_at ? new Date(task.due_at) : null; + const dueKey = dueDate ? dayKey(dueDate) : null; + if (!startKey && !dueKey) return null; + + const anchor = startKey ?? (dueKey as string); + if (anchor === day) return null; + + const offset = Math.round( + (fromDayKey(day).getTime() - fromDayKey(anchor).getTime()) / DAY_MS, + ); + const patch: { start_date?: string | null; due_at?: string | null } = {}; + // The anchor IS the start when there is one, so the new start is simply the + // day it was dropped on. Written as `shiftDay(startKey, offset)` first, which + // is the same value by construction and reads as if it could differ. + if (startKey) patch.start_date = day; + if (dueKey && dueDate) { + const moved = fromDayKey(shiftDay(dueKey, offset)); + moved.setHours( + dueDate.getHours(), dueDate.getMinutes(), + dueDate.getSeconds(), dueDate.getMilliseconds(), + ); + patch.due_at = moved.toISOString(); + } + return patch; +} + +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +/** "August 2026", for the grid's heading. */ +export function monthLabel(grid: MonthGrid): string { + const [year, month] = grid.month.split("-").map(Number); + return `${MONTHS[(month ?? 1) - 1]} ${year}`; +} + +/** True when a grid day belongs to a neighbouring month. */ +export function isOutsideMonth(day: string, grid: MonthGrid): boolean { + return day.slice(0, 7) !== grid.month; +} + +/** The month `n` months from the grid's own, as an anchor Date. */ +export function shiftMonth(grid: MonthGrid, months: number): Date { + const [year, month] = grid.month.split("-").map(Number); + return new Date(year, (month ?? 1) - 1 + months, 1); +} diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx index 44745a80..838cc3d1 100644 --- a/workbench/control_plane/src/app/projects/page.tsx +++ b/workbench/control_plane/src/app/projects/page.tsx @@ -34,10 +34,12 @@ import { ImportClickUp } from "./components/ImportClickUp"; import { MyWork } from "./components/MyWork"; import { NotificationBell } from "./components/NotificationBell"; import { ProjectTree } from "./components/ProjectTree"; +import { CalendarView } from "./components/CalendarView"; import { TaskBoard } from "./components/TaskBoard"; import { TaskList } from "./components/TaskList"; import { TaskPanel } from "./components/TaskPanel"; import { SAVED_VIEW_POSITION, orderBearingView, type planDrop } from "./lib/board"; +import { calendarWindow, dayKey, monthGrid, shiftMonth } from "./lib/calendar"; import { EMPTY_FILTERS, type Filters, @@ -59,7 +61,11 @@ import { import { fetchAccess } from "@/lib/access"; import { filterByCenter, flatten } from "./lib/tree"; -type ViewMode = "board" | "list"; +type ViewMode = "board" | "list" | "calendar"; + +/** An empty calendar window — the shape before anything has been fetched, and + * the shape after a failure, so the view never renders a stale month. */ +const NO_MONTH = { rows: [] as TaskRow[], undated: 0, truncated: false }; function ProjectsWorkspace() { const searchParams = useSearchParams(); @@ -117,6 +123,12 @@ function ProjectsWorkspace() { // WS-27n — multi-select. `anchor` is the last card clicked without shift, // which is what a shift-click measures its range from. + // WS-27q — the calendar is a WINDOW, not the paged task list, so it holds + // its own rows. Sharing `tasks` would mean either paginating the calendar + // (a month with silently missing days) or unpaginating the board. + const [monthAnchor, setMonthAnchor] = useState(() => new Date()); + const [month, setMonth] = useState(NO_MONTH); + const [picked, setPicked] = useState>(new Set()); const [anchor, setAnchor] = useState(null); const [bulkBusy, setBulkBusy] = useState(false); @@ -209,6 +221,42 @@ function ProjectsWorkspace() { if (selected) void loadProject(selected); }, [selected, loadProject]); + // WS-27q — the calendar's own fetch, because it reads a WINDOW rather than a + // page. `grid` is derived so the effect re-runs when the month steps, and + // `calendarWindow` adds the day of slack the endpoint's UTC reading needs. + const grid = useMemo(() => monthGrid(monthAnchor), [monthAnchor]); + + const loadMonth = useCallback(async () => { + if (!selected) { + setMonth(NO_MONTH); + return; + } + const { from, to } = calendarWindow(grid); + try { + const res = await projectsApi.calendar({ + project_id: selected.id, + include_subtree: true, + from, + to, + ...toQuery(filters), + }); + setMonth({ + rows: res.rows, + undated: res.undated, + truncated: res.truncated, + }); + } catch (err) { + setError(String((err as Error).message)); + // Cleared rather than left as it was: a stale month drawn under a new + // heading is a calendar confidently showing the wrong dates. + setMonth(NO_MONTH); + } + }, [selected, grid, filters]); + + useEffect(() => { + if (mode === "calendar") void loadMonth(); + }, [mode, loadMonth]); + useEffect(() => { if (!selected) { setFields([]); @@ -463,6 +511,34 @@ function ProjectsWorkspace() { } } + /** + * WS-27q — a task dragged to another day. + * + * A plain `PATCH`, deliberately: the same validation, the same + * `field_change` activity and the same revert as an edit typed into the + * panel. A dedicated "move" endpoint would be a second write path, which is + * how two paths start disagreeing about what is allowed. + * + * Optimistic like the board's drop, and for the same reason — a drag that + * waits for a round trip feels broken even when it is correct. `rescheduleTo` + * has already refused a no-op, so this never posts an activity saying a task + * moved to where it already was. + */ + async function moveTask(task: TaskRow, patch: Record) { + setMonth((current) => ({ + ...current, + rows: current.rows.map((t) => (t.id === task.id ? { ...t, ...patch } : t)), + })); + try { + await projectsApi.patchTask(task.id, patch); + } catch (err) { + setError(String((err as Error).message)); + } + // Reloaded either way: on success to pick up anything the server derived, + // on failure to replace the optimistic move with the truth. + await loadMonth(); + } + async function handleDrop( task: TaskRow, writes: ReturnType, @@ -613,7 +689,7 @@ function ProjectsWorkspace() {
- {(["board", "list"] as ViewMode[]).map((m) => ( + {(["board", "list", "calendar"] as ViewMode[]).map((m) => ( +
+ ))} +
+ + {/* Chart */} +
+
+ {months.map((cell) => ( +
+ {cell.widthPx > 48 ? cell.label : ""} +
+ ))} +
+ +
+ {todayPx !== null ? ( +
+ ) : null} + + {/* Arrows, under the bars so a bar is never un-clickable. */} + + + + + + + + + + {links.map((edge) => { + const from = indexById.get(edge.blocker_id); + const to = indexById.get(edge.blocked_id); + if (from === undefined || to === undefined) return null; + const d = edgePath( + { bar: barById.get(edge.blocker_id) ?? null, row: from }, + { bar: barById.get(edge.blocked_id) ?? null, row: to }, + ); + if (!d) return null; + const blocker = taskById.get(edge.blocker_id); + const blocked = taskById.get(edge.blocked_id); + const bad = + !!blocker && !!blocked && conflicts(blocker, blocked); + return ( + + ); + })} + + + {drawn.map((row, index) => { + const drawnBar = barById.get(row.task.id) ?? null; + const blocker = links.find((l) => l.blocked_id === row.task.id); + const blockerTask = blocker + ? taskById.get(blocker.blocker_id) + : undefined; + const bad = + !!blockerTask && conflicts(blockerTask, row.task); + return ( +
{ + if (dragging) e.preventDefault(); + }} + onDrop={(e) => { + e.preventDefault(); + drop(row.task.id); + }} + > + {drawnBar ? ( +
+ + {/* The link handle. Only on a real bar: a derived one + has no dates of its own, so a dependency drawn from + it would be about days nobody typed. */} + {drawnBar.derived ? null : ( + setDragging(row.task.id)} + onDragEnd={() => setDragging(null)} + title={`Drag onto another bar: "${row.task.title}" blocks it`} + className="absolute -right-2 h-3 w-3 cursor-grab rounded-full border border-border bg-card opacity-0 transition-opacity group-hover:opacity-100" + /> + )} +
+ ) : ( + + unscheduled + + )} +
+ ); + })} +
+
+
+
+ +

+ A red arrow means a task starts before the thing blocking it is due to + finish. Nothing is rescheduled automatically — the dates stay yours. + + Showing {range.from} to {shiftDay(range.to, 0)}, {PX_PER_DAY}px per day. + +

+ + ); +} diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts index 85bc528b..a376970c 100644 --- a/workbench/control_plane/src/app/projects/lib/api.ts +++ b/workbench/control_plane/src/app/projects/lib/api.ts @@ -208,6 +208,13 @@ export const projectsApi = { from: string; to: string; rows: TaskRow[]; + /** + * WS-27t — the `blocks` edges with BOTH ends in the window, so an arrow + * always has two bars to join. Empty unless `include_links`, and always + * present: a missing key and an empty list read the same to a careless + * client. + */ + links: { id: string; blocker_id: string; blocked_id: string }[]; truncated: boolean; cap: number; undated: number; diff --git a/workbench/control_plane/src/app/projects/lib/relations.ts b/workbench/control_plane/src/app/projects/lib/relations.ts index a84806d6..ab3d829e 100644 --- a/workbench/control_plane/src/app/projects/lib/relations.ts +++ b/workbench/control_plane/src/app/projects/lib/relations.ts @@ -22,6 +22,15 @@ export interface RelatedTask { status_name?: string | null; category?: string | null; completed_at?: string | null; + /** + * WS-27t — carried so the schedule-conflict warning (D-PM-12) can be + * computed from the SAME pure rule the timeline draws its red arrows with. + * Two implementations of "does this start before its blocker finishes" would + * eventually disagree, and the surface that got it wrong would be the one + * nobody was looking at. + */ + start_date?: string | null; + due_at?: string | null; } export interface SubtaskRow { @@ -32,6 +41,8 @@ export interface SubtaskRow { status_name?: string | null; category?: string | null; completed_at?: string | null; + start_date?: string | null; + due_at?: string | null; } export interface Relations { diff --git a/workbench/control_plane/src/app/projects/lib/timeline.test.ts b/workbench/control_plane/src/app/projects/lib/timeline.test.ts new file mode 100644 index 00000000..3c4f373a --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/timeline.test.ts @@ -0,0 +1,503 @@ +/** + * WS-27t — the timeline's arithmetic and its two decided rules. + * + * The claims that matter here are not "does the bar render". They are the ones + * where a plausible implementation is wrong in a way that looks fine: + * + * * **a bar covers its last day.** Stopping at the last day's left edge makes a + * one-day task a zero-width line and every span one day short — a chart that + * is subtly, consistently lying about durations. + * * **equal dates are not a conflict** (D-PM-12). A blocker due the 10th and a + * task starting the 10th is the normal way people schedule a handover. + * Flagging it fires the warning on half a healthy plan, after which nobody + * reads it. + * * **a subtask whose parent is off-window is promoted, not hidden.** Hiding it + * makes a filtered timeline silently drop work. + * * **a parent with no dates borrows its children's span**, and says it did. + * Without that the depth-grouped default view looks empty for exactly the + * projects that use subtasks properly. + * * **the cycle check is NOT reimplemented here.** `assert_no_block_cycle` owns + * it; a browser copy is the one that drifts. + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import type { TaskRow } from "./api"; +import { fromDayKey } from "./calendar"; +import { + MIN_BAR_PX, + PAD_DAYS, + PX_PER_DAY, + ROW_H, + bar, + canLink, + conflictLabel, + conflicts, + dayPx, + edgePath, + interval, + monthCells, + rowInterval, + timelineRange, + timelineRows, +} from "./timeline"; + +/** The module's own source, with comments stripped. + * + * Stripped because these assertions are about what the CODE does, and this + * module's prose is dense enough that "stays readable while it is wrong" + * matched a search for a `while` loop. A structural test that trips on its own + * documentation is a test people delete. */ +const SOURCE = readFileSync( + fileURLToPath(new URL("./timeline.ts", import.meta.url)), + "utf8", +) + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + +const task = (over: Partial = {}): TaskRow => ({ + id: "t1", + project_id: "p1", + root_project_id: "p1", + status_id: "s1", + title: "Ship it", + ...over, +}); + +/** A local-noon instant for a day key, so `due_at` fixtures are timezone-proof. */ +const at = (key: string) => { + const d = fromDayKey(key); + d.setHours(12, 0, 0, 0); + return d.toISOString(); +}; + +const RANGE = timelineRange( + [{ task: task({ start_date: "2026-08-01", due_at: at("2026-08-31") }), depth: 0, children: [] }], + "2026-08-15", +); + +// ── interval ──────────────────────────────────────────────────────────────── + +describe("interval", () => { + it("spans start to due", () => { + expect(interval(task({ start_date: "2026-08-03", due_at: at("2026-08-07") }))) + .toEqual({ from: "2026-08-03", to: "2026-08-07" }); + }); + + it("is a point when only one date is known", () => { + expect(interval(task({ start_date: "2026-08-03" }))) + .toEqual({ from: "2026-08-03", to: "2026-08-03" }); + expect(interval(task({ due_at: at("2026-08-07") }))) + .toEqual({ from: "2026-08-07", to: "2026-08-07" }); + }); + + it("is null when the task has no dates", () => { + expect(interval(task())).toBeNull(); + }); + + it("normalises a backwards interval rather than dropping the task", () => { + // The timeline is the one view that makes bad data obvious. Returning null + // would hide exactly the task somebody needs to see. + expect(interval(task({ start_date: "2026-08-20", due_at: at("2026-08-18") }))) + .toEqual({ from: "2026-08-18", to: "2026-08-20" }); + }); + + it("never routes a start_date through the Date constructor", () => { + // ⚠️ Structural, because the behavioural version only fails WEST of + // Greenwich and CI runs one timezone — the lesson from WS-27q. + expect(SOURCE).not.toMatch(/new Date\(\s*(task\.)?start_?[Dd]ate/); + }); +}); + +// ── rowInterval — D-PM-11's roll-up ───────────────────────────────────────── + +describe("rowInterval", () => { + it("prefers the task's own dates over its children's", () => { + expect( + rowInterval(task({ start_date: "2026-08-01", due_at: at("2026-08-02") }), [ + task({ id: "c", start_date: "2026-01-01", due_at: at("2026-12-31") }), + ]), + ).toEqual({ from: "2026-08-01", to: "2026-08-02", derived: false }); + }); + + it("borrows the children's span when the parent has no dates", () => { + // ⚠️ Without this a depth-grouped timeline looks EMPTY for exactly the + // projects that use subtasks properly. + expect( + rowInterval(task(), [ + task({ id: "a", start_date: "2026-08-04", due_at: at("2026-08-06") }), + task({ id: "b", start_date: "2026-08-02", due_at: at("2026-08-09") }), + ]), + ).toEqual({ from: "2026-08-02", to: "2026-08-09", derived: true }); + }); + + it("marks a borrowed span as derived so the UI can say so", () => { + const derived = rowInterval(task(), [task({ id: "a", start_date: "2026-08-04" })]); + expect(derived?.derived).toBe(true); + }); + + it("ignores children that have no dates of their own", () => { + expect( + rowInterval(task(), [ + task({ id: "a" }), + task({ id: "b", start_date: "2026-08-04" }), + ]), + ).toEqual({ from: "2026-08-04", to: "2026-08-04", derived: true }); + }); + + it("is null when neither the parent nor any child has a date", () => { + expect(rowInterval(task(), [task({ id: "a" })])).toBeNull(); + }); +}); + +// ── timelineRows — D-PM-11's scoping ──────────────────────────────────────── + +describe("timelineRows", () => { + it("gives every top-level task a row", () => { + const rows = timelineRows([task({ id: "a" }), task({ id: "b" })]); + expect(rows.map((r) => r.task.id)).toEqual(["a", "b"]); + }); + + it("folds a subtask under its parent instead of giving it a row", () => { + const rows = timelineRows([ + task({ id: "parent" }), + task({ id: "kid", parent_task_id: "parent" }), + ]); + expect(rows.map((r) => r.task.id)).toEqual(["parent"]); + expect(rows[0].children.map((c) => c.id)).toEqual(["kid"]); + }); + + it("promotes a subtask whose parent is not in the window", () => { + // ⚠️ Hidden, a filtered timeline silently drops work — the `undated` + // failure one level down. + const rows = timelineRows([task({ id: "orphan", parent_task_id: "elsewhere" })]); + expect(rows.map((r) => r.task.id)).toEqual(["orphan"]); + expect(rows[0].children).toEqual([]); + }); + + it("keeps the order it was given, which is the server's", () => { + const rows = timelineRows([ + task({ id: "c" }), task({ id: "a" }), task({ id: "b" }), + ]); + expect(rows.map((r) => r.task.id)).toEqual(["c", "a", "b"]); + }); + + it("handles a task that claims itself as its parent", () => { + // The gateway refuses this (assert_no_task_cycle), so it can only arrive + // from corrupt data — and an infinite loop in the renderer is a worse + // outcome than a row. + const rows = timelineRows([task({ id: "a", parent_task_id: "a" })]); + expect(rows.map((r) => r.task.id)).toEqual([]); + }); +}); + +// ── the range and the axis ────────────────────────────────────────────────── + +describe("timelineRange", () => { + it("pads the data's own span on both sides", () => { + const range = timelineRange( + [{ task: task({ start_date: "2026-08-10", due_at: at("2026-08-20") }), depth: 0, children: [] }], + "2026-08-15", + ); + expect(range.from).toBe("2026-08-03"); + expect(range.to).toBe("2026-08-27"); + expect(range.days).toBe(10 + 1 + PAD_DAYS * 2); + }); + + it("falls back to a fortnight around today when nothing is dated", () => { + // An empty chart still needs an axis to read, and a zero-width one cannot + // render at all. + const range = timelineRange( + [{ task: task(), depth: 0, children: [] }], + "2026-08-15", + ); + expect(range.from).toBe("2026-08-01"); + expect(range.to).toBe("2026-08-29"); + expect(range.widthPx).toBeGreaterThan(0); + }); + + it("covers a child's dates when only the child has them", () => { + const range = timelineRange( + [{ task: task(), depth: 0, children: [task({ id: "c", start_date: "2026-09-10" })] }], + "2026-08-15", + ); + expect(range.from <= "2026-09-10").toBe(true); + expect(range.to >= "2026-09-10").toBe(true); + }); + + it("measures its width from its own day count", () => { + expect(RANGE.widthPx).toBe(RANGE.days * PX_PER_DAY); + }); +}); + +describe("dayPx", () => { + it("puts the first day at zero", () => { + expect(dayPx(RANGE.from, RANGE)).toBe(0); + }); + + it("advances one day at a time", () => { + expect(dayPx("2026-07-26", RANGE) - dayPx("2026-07-25", RANGE)).toBe(PX_PER_DAY); + }); + + it("survives a DST boundary without drifting a day", () => { + // ⚠️ Millisecond arithmetic across a DST change is 23 or 25 hours, so an + // unrounded division lands a fraction of a day off for every day after the + // transition — and stays wrong for the rest of the chart. + // + // The range must STRADDLE a transition for this to bite: February to + // August crosses the spring-forward in every northern DST zone, whereas + // two days either side of midsummer are in the same regime and would pass + // with the rounding removed. That was the first version of this test. + const range = timelineRange( + [{ task: task({ start_date: "2026-02-01", due_at: at("2026-08-31") }), depth: 0, children: [] }], + "2026-05-01", + ); + expect(dayPx("2026-08-02", range) - dayPx("2026-08-01", range)).toBe(PX_PER_DAY); + expect(dayPx(range.to, range) + PX_PER_DAY).toBe(range.widthPx); + expect(dayPx("2026-08-01", range) % PX_PER_DAY).toBe(0); + }); + + it("rounds the day count rather than trusting the millisecond division", () => { + // ⚠️ Structural, and needed for the same reason as the `start_date` trap: + // the behavioural test above can only fail in a timezone that HAS daylight + // saving. In UTC — which is what CI runs — the drift is exactly zero and + // the bug is invisible. + const body = SOURCE.slice(SOURCE.indexOf("export function dayPx")); + expect(body.slice(0, 300)).toContain("Math.round("); + }); +}); + +describe("monthCells", () => { + it("labels each month once, in order", () => { + const cells = monthCells(RANGE); + expect(cells.map((c) => c.key)).toEqual(["2026-07", "2026-08", "2026-09"]); + expect(cells[1].label).toBe("Aug 2026"); + }); + + it("tiles the whole width with no gaps or overlaps", () => { + // ⚠️ A clipped first cell is the easy bug: the range starts mid-July, so + // that cell is short and every later cell shifts if it is not. + const cells = monthCells(RANGE); + expect(cells[0].px).toBe(0); + for (let i = 1; i < cells.length; i += 1) { + expect(cells[i].px).toBe(cells[i - 1].px + cells[i - 1].widthPx); + } + const last = cells[cells.length - 1]; + expect(last.px + last.widthPx).toBe(RANGE.widthPx); + }); + + it("handles a range inside a single month", () => { + const range = timelineRange( + [{ task: task({ start_date: "2026-08-10", due_at: at("2026-08-12") }), depth: 0, children: [] }], + "2026-08-11", + ); + const cells = monthCells(range); + expect(cells.map((c) => c.key)).toEqual(["2026-08"]); + expect(cells[0].widthPx).toBe(range.widthPx); + }); + + it("crosses a year boundary", () => { + const range = timelineRange( + [{ task: task({ start_date: "2026-12-20", due_at: at("2027-01-10") }), depth: 0, children: [] }], + "2026-12-25", + ); + expect(monthCells(range).map((c) => c.key)).toEqual(["2026-12", "2027-01"]); + }); +}); + +// ── bars ──────────────────────────────────────────────────────────────────── + +describe("bar", () => { + it("covers the LAST day, not up to its left edge", () => { + // ⚠️ The off-by-one that makes every span one day short and a one-day task + // a zero-width line. Aug 10–12 is three days of chart. + const drawn = bar( + task({ start_date: "2026-08-10", due_at: at("2026-08-12") }), [], RANGE, + ); + expect(drawn?.widthPx).toBe(3 * PX_PER_DAY); + }); + + it("draws a single-date task at least wide enough to click", () => { + const drawn = bar(task({ due_at: at("2026-08-12") }), [], RANGE); + expect(drawn?.singleDate).toBe(true); + expect(drawn?.widthPx).toBeGreaterThanOrEqual(MIN_BAR_PX); + }); + + it("starts where the range says its first day starts", () => { + const drawn = bar(task({ start_date: "2026-08-10" }), [], RANGE); + expect(drawn?.leftPx).toBe(dayPx("2026-08-10", RANGE)); + }); + + it("is null for a task with no dates and no dated children", () => { + expect(bar(task(), [], RANGE)).toBeNull(); + }); + + it("marks a bar borrowed from children as derived", () => { + const drawn = bar(task(), [task({ id: "c", start_date: "2026-08-11" })], RANGE); + expect(drawn?.derived).toBe(true); + }); +}); + +// ── D-PM-12 — the conflict rule ───────────────────────────────────────────── + +describe("conflicts", () => { + const blocker = (over: Partial = {}) => task({ id: "blocker", ...over }); + const blocked = (over: Partial = {}) => task({ id: "blocked", ...over }); + + it("fires when the blocker finishes after the blocked task starts", () => { + expect( + conflicts( + blocker({ start_date: "2026-08-01", due_at: at("2026-08-12") }), + blocked({ start_date: "2026-08-10", due_at: at("2026-08-20") }), + ), + ).toBe(true); + }); + + it("does NOT fire when they merely touch", () => { + // ⚠️ The decision that keeps the warning worth reading. A blocker due the + // 10th and a task starting the 10th is a normal handover; flagging it + // fires on half a healthy plan. + expect( + conflicts( + blocker({ due_at: at("2026-08-10") }), + blocked({ start_date: "2026-08-10" }), + ), + ).toBe(false); + }); + + it("does not fire on a well-ordered pair", () => { + expect( + conflicts( + blocker({ due_at: at("2026-08-05") }), + blocked({ start_date: "2026-08-10" }), + ), + ).toBe(false); + }); + + it("does not fire when either end has no dates", () => { + // ⚠️ A warning that fires on absent data teaches people it means nothing. + expect(conflicts(blocker(), blocked({ start_date: "2026-08-01" }))).toBe(false); + expect(conflicts(blocker({ due_at: at("2026-08-20") }), blocked())).toBe(false); + }); + + it("never fires for a finished blocker", () => { + // WS-27p: a resolved blocker blocks nothing. The same rule, applied to the + // warning rather than to the badge. + expect( + conflicts( + blocker({ due_at: at("2026-08-20"), completed_at: at("2026-08-01") }), + blocked({ start_date: "2026-08-10" }), + ), + ).toBe(false); + }); + + it("uses the blocker's END, not its start", () => { + // A blocker STARTING after the blocked task is fine as long as it finishes + // first — unusual, but not a contradiction the chart should shout about. + expect( + conflicts( + blocker({ start_date: "2026-08-12", due_at: at("2026-08-12") }), + blocked({ start_date: "2026-08-14" }), + ), + ).toBe(false); + }); + + it("says what happened and that nothing was moved", () => { + // ⚠️ D-PM-12 chose warn-over-push. The sentence has to say so, or users + // assume the tool fixed it. + const label = conflictLabel("Design sign-off"); + expect(label).toContain("Design sign-off"); + expect(label.toLowerCase()).toContain("nothing has been rescheduled"); + }); + + it("writes nothing — the module holds no PATCH or reschedule", () => { + // ⚠️ The structural half of D-PM-12. A later "helpful" auto-push would be + // a decision reversal, not a refactor, and this is what makes it visible. + expect(SOURCE).not.toMatch(/patchTask|projectsApi|fetch\(/); + }); +}); + +// ── arrows ────────────────────────────────────────────────────────────────── + +describe("edgePath", () => { + const barAt = (leftPx: number, widthPx: number) => + ({ leftPx, widthPx, singleDate: false, derived: false }); + + it("routes forwards when there is room", () => { + const d = edgePath( + { bar: barAt(0, 50), row: 0 }, + { bar: barAt(200, 50), row: 2 }, + ); + expect(d).toBe(`M 50 ${ROW_H / 2} H 125 V ${2 * ROW_H + ROW_H / 2} H 200`); + }); + + it("routes around when the target starts before the source ends", () => { + // The conflict geometry: a straight path would run backwards through both + // bars. It stays readable while it is wrong. + const d = edgePath( + { bar: barAt(100, 100), row: 0 }, + { bar: barAt(120, 60), row: 1 }, + ) as string; + expect(d.split("V").length).toBe(3); + expect(d.startsWith("M 200")).toBe(true); + expect(d.endsWith("H 120")).toBe(true); + }); + + it("is null when either end has no bar", () => { + // ⚠️ An arrow to an undated task has nowhere to land, and drawing it to the + // row's margin invents a date the task does not have. + expect(edgePath({ bar: null, row: 0 }, { bar: barAt(0, 10), row: 1 })).toBeNull(); + expect(edgePath({ bar: barAt(0, 10), row: 0 }, { bar: null, row: 1 })).toBeNull(); + }); + + it("centres on the row, so the arrow meets the middle of a bar", () => { + const d = edgePath( + { bar: barAt(0, 10), row: 3 }, + { bar: barAt(500, 10), row: 3 }, + ) as string; + expect(d).toContain(`M 10 ${3 * ROW_H + ROW_H / 2}`); + }); +}); + +// ── canLink ───────────────────────────────────────────────────────────────── + +describe("canLink", () => { + it("allows a fresh dependency", () => { + expect(canLink("a", "b", [])).toEqual({ ok: true }); + }); + + it("refuses a task blocking itself", () => { + expect(canLink("a", "a", [])).toEqual({ + ok: false, + reason: "A task cannot block itself.", + }); + }); + + it("refuses a duplicate rather than creating a second identical edge", () => { + expect( + canLink("a", "b", [{ id: "l1", blocker_id: "a", blocked_id: "b" }]), + ).toMatchObject({ ok: false }); + }); + + it("allows the REVERSE of an existing edge through, for the gateway to refuse", () => { + // ⚠️ a→b then b→a is a two-node cycle. It is refused, but by + // `assert_no_block_cycle` — bounded, tested and shared with every other + // caller. A second implementation here is the one that would drift. + expect( + canLink("b", "a", [{ id: "l1", blocker_id: "a", blocked_id: "b" }]), + ).toEqual({ ok: true }); + }); + + it("does not reimplement the cycle walk", () => { + // The cycle walk's own vocabulary, not "does this file contain a loop" — + // `monthCells` legitimately walks months with a `while`, and a structural + // test that cannot tell the two apart is one that gets deleted the first + // time it is wrong. + expect(SOURCE).not.toMatch(/MAX_DEPTH|frontier|\bvisited\b/); + const body = SOURCE.slice(SOURCE.indexOf("export function canLink")); + expect(body).not.toMatch(/\bwhile\b|\bfor\s*\(/); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/timeline.ts b/workbench/control_plane/src/app/projects/lib/timeline.ts new file mode 100644 index 00000000..af850a71 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/timeline.ts @@ -0,0 +1,322 @@ +/** + * Projects · the timeline, as arithmetic (WS-27t). + * + * A Gantt chart is bar geometry plus two rules that are not geometry at all, + * and both were decisions rather than defaults: + * + * * **D-PM-11 — what earns a bar.** Hierarchy depth: top-level tasks get rows, + * subtasks fold into their parent and expand on demand. Paca's Timeline + * pre-filters to a reserved `Epic` type instead; that was rejected because + * `pm_task_types` is per-project data with no reserved names (D-PM-2), so + * "Epic" would have to become either a seeded row every project inherits or + * a name-match that silently stops working the day somebody renames a type. + * `parent_task_id` already says what depth means and cannot be renamed. + * + * * **D-PM-12 — an arrow WARNS, it does not push.** A `blocks` edge whose + * blocker finishes after the blocked task starts is drawn in the danger tone + * and says so. Nothing is rescheduled. Jira drags the dependents forward; + * that was rejected because it contradicts WS-27p's "derived and shown, never + * enforced" and turns one drag into an unbounded cascade of real `PATCH`es, + * each with its own activity row and notification. + * + * Dates are `YYYY-MM-DD` keys throughout, for the reason `lib/calendar.ts` + * gives at length: `new Date("2026-08-07")` is midnight UTC, which is the 6th + * anywhere west of Greenwich. + */ + +import { dayKey, fromDayKey, shiftDay } from "./calendar"; + +import type { TaskRow } from "./api"; + +/** Chart pixels per calendar day. */ +export const PX_PER_DAY = 24; +/** Height of one task row, in pixels. Rows are uniform so `y` is index × this. */ +export const ROW_H = 34; +/** Days of breathing room either side of the data's own range. */ +export const PAD_DAYS = 7; +/** Narrowest a bar may be drawn — a one-day task must still be clickable. */ +export const MIN_BAR_PX = 10; + +const DAY_MS = 86_400_000; + +export interface TimelineRange { + from: string; + to: string; + days: number; + widthPx: number; +} + +export interface Bar { + leftPx: number; + widthPx: number; + /** Only one date is known, so the bar is a marker rather than a span. */ + singleDate: boolean; + /** The interval came from this task's CHILDREN, not from its own dates. */ + derived: boolean; +} + +export interface TimelineRow { + task: TaskRow; + depth: number; + /** Subtasks of this row present in the window — drawn when expanded. */ + children: TaskRow[]; +} + +export interface Edge { + id: string; + blocker_id: string; + blocked_id: string; +} + +/** A task's own scheduled interval, or `null` when it has no dates. */ +export function interval(task: TaskRow): { from: string; to: string } | null { + const start = task.start_date ? task.start_date.slice(0, 10) : null; + const due = task.due_at ? dayKey(new Date(task.due_at)) : null; + if (!start && !due) return null; + const a = start ?? (due as string); + const b = due ?? (start as string); + // Bad data — due before start — is shown as the span it implies rather than + // dropped: the timeline is the one view that would have made it obvious. + return a <= b ? { from: a, to: b } : { from: b, to: a }; +} + +/** + * The interval a ROW occupies, folding in its children. + * + * A parent with no dates of its own still gets a bar when its subtasks have + * them — that is the point of grouping by depth, and a parent drawn as a blank + * row while its children carry the schedule would make the default view look + * empty. `derived` marks it so the UI can say the dates were not typed here. + */ +export function rowInterval( + task: TaskRow, + children: readonly TaskRow[], +): { from: string; to: string; derived: boolean } | null { + const own = interval(task); + if (own) return { ...own, derived: false }; + const spans = children.map(interval).filter(Boolean) as { from: string; to: string }[]; + if (spans.length === 0) return null; + return { + from: spans.reduce((lo, s) => (s.from < lo ? s.from : lo), spans[0].from), + to: spans.reduce((hi, s) => (s.to > hi ? s.to : hi), spans[0].to), + derived: true, + }; +} + +/** + * Group the window's tasks into rows by hierarchy depth (D-PM-11). + * + * **A subtask whose parent is not in the window is promoted to a row of its + * own**, rather than hidden under a parent that is not there. Hiding it would + * make a filtered timeline silently drop work — the same failure the `undated` + * count exists to prevent, one level down. + */ +export function timelineRows(tasks: readonly TaskRow[]): TimelineRow[] { + const present = new Set(tasks.map((t) => t.id)); + const childrenOf = new Map(); + const roots: TaskRow[] = []; + + for (const task of tasks) { + const parent = task.parent_task_id; + if (parent && present.has(parent)) { + const kids = childrenOf.get(parent) ?? []; + kids.push(task); + childrenOf.set(parent, kids); + } else { + roots.push(task); + } + } + + return roots.map((task) => ({ + task, + depth: 0, + children: childrenOf.get(task.id) ?? [], + })); +} + +/** + * The date range the chart covers: the data's own span, padded. + * + * Fitted to the data rather than to a fixed month, because a timeline's + * question is "what runs alongside what" and a window that clips the answer is + * the wrong window. Falls back to a fortnight around today when nothing in the + * set has a date at all, so an empty chart still has an axis to read. + */ +export function timelineRange( + rows: readonly TimelineRow[], + todayKey: string, +): TimelineRange { + const spans = rows + .map((r) => rowInterval(r.task, r.children)) + .filter(Boolean) as { from: string; to: string }[]; + + const from = spans.length + ? shiftDay(spans.reduce((lo, s) => (s.from < lo ? s.from : lo), spans[0].from), -PAD_DAYS) + : shiftDay(todayKey, -14); + const to = spans.length + ? shiftDay(spans.reduce((hi, s) => (s.to > hi ? s.to : hi), spans[0].to), PAD_DAYS) + : shiftDay(todayKey, 14); + + const days = + Math.round((fromDayKey(to).getTime() - fromDayKey(from).getTime()) / DAY_MS) + 1; + return { from, to, days, widthPx: days * PX_PER_DAY }; +} + +/** Pixels from the chart's left edge to the START of a day. */ +export function dayPx(day: string, range: TimelineRange): number { + const offset = + (fromDayKey(day).getTime() - fromDayKey(range.from).getTime()) / DAY_MS; + return Math.round(offset) * PX_PER_DAY; +} + +/** + * Where a row's bar sits, or `null` when it has no dates anywhere. + * + * A bar covers its last day rather than stopping at that day's left edge — a + * task starting and ending on Tuesday must cover Tuesday, not be a zero-width + * line at its start. + */ +export function bar( + task: TaskRow, + children: readonly TaskRow[], + range: TimelineRange, +): Bar | null { + const span = rowInterval(task, children); + if (!span) return null; + const leftPx = dayPx(span.from, range); + const rightPx = dayPx(span.to, range) + PX_PER_DAY; + return { + leftPx, + widthPx: Math.max(MIN_BAR_PX, rightPx - leftPx), + singleDate: span.from === span.to, + derived: span.derived, + }; +} + +export interface MonthCell { + key: string; + label: string; + px: number; + widthPx: number; +} + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/** Month header cells across the range, clipped to it at both ends. */ +export function monthCells(range: TimelineRange): MonthCell[] { + const out: MonthCell[] = []; + let cursor = range.from; + while (cursor <= range.to) { + const [year, month] = cursor.split("-").map(Number); + const firstOfNext = dayKey(new Date(year, month, 1)); + const end = firstOfNext <= range.to ? shiftDay(firstOfNext, -1) : range.to; + const px = dayPx(cursor, range); + out.push({ + key: cursor.slice(0, 7), + label: `${MONTHS[month - 1]} ${year}`, + px, + widthPx: dayPx(end, range) + PX_PER_DAY - px, + }); + cursor = firstOfNext; + } + return out; +} + +/** + * Does this dependency disagree with the schedule? (D-PM-12) + * + * A `blocks` edge asserts the blocker finishes before the blocked task starts. + * It is violated when the blocker's END is strictly after the blocked task's + * START — the two overlap, so the sequence the arrow claims cannot happen. + * + * **Equal dates are NOT a conflict.** A blocker due on the 10th and a task + * starting on the 10th is the normal way people schedule a handover; flagging + * it would make the warning fire on half a healthy plan and be ignored within a + * week. + * + * **An edge with a date missing on either end is not a conflict either** — it + * is unknowable, and a warning that fires on absent data teaches people that + * the warning means nothing. + * + * **A finished blocker never conflicts.** It has already happened; the dates + * are history, and WS-27p's rule that a resolved blocker blocks nothing applies + * to the warning exactly as it applies to the badge. + */ +export function conflicts( + blocker: Pick, + blocked: Pick, +): boolean { + if (blocker.completed_at) return false; + const before = interval(blocker as TaskRow); + const after = interval(blocked as TaskRow); + if (!before || !after) return false; + return before.to > after.from; +} + +/** A one-sentence explanation of a conflict, for the warning's title. */ +export function conflictLabel(blockerTitle: string): string { + return `Starts before "${blockerTitle}" is due to finish. Nothing has been ` + + `rescheduled — the dates are yours to fix.`; +} + +/** + * The elbow path from one bar's right edge to another's left edge. + * + * Elbowed rather than straight, and routed OUT of the source before turning, + * because a straight diagonal across six rows crosses every bar between them + * and stops being followable at exactly the density where you need it. + * + * Returns `null` when either end has no bar: an arrow to a task with no dates + * has nowhere to land, and drawing it to the row's left margin would invent a + * date the task does not have. + */ +export function edgePath( + from: { bar: Bar | null; row: number }, + to: { bar: Bar | null; row: number }, +): string | null { + if (!from.bar || !to.bar) return null; + const y1 = from.row * ROW_H + ROW_H / 2; + const y2 = to.row * ROW_H + ROW_H / 2; + const x1 = from.bar.leftPx + from.bar.widthPx; + const x2 = to.bar.leftPx; + const stub = 10; + + // Room to route forwards: out, across, in. + if (x2 >= x1 + stub * 2) { + const mid = (x1 + x2) / 2; + return `M ${x1} ${y1} H ${mid} V ${y2} H ${x2}`; + } + // The blocked bar starts at or before the blocker ends — the conflict case, + // and the one a naive path draws backwards through both bars. Route around + // below/above instead so the arrow stays readable while it is wrong. + const lane = (Math.max(y1, y2) + ROW_H / 2 + Math.min(y1, y2)) / 2; + return ( + `M ${x1} ${y1} H ${x1 + stub} V ${lane} H ${x2 - stub} V ${y2} H ${x2}` + ); +} + +/** + * May this drag create a link? + * + * Only the cheap, local refusals — a task cannot block itself, and an edge that + * already exists is a no-op rather than a duplicate. **The cycle check is NOT + * duplicated here**: `assert_no_block_cycle` owns it, bounded and tested, and a + * second implementation in the browser would be the one that drifts. The drop + * posts and reports the gateway's own refusal message. + */ +export function canLink( + blockerId: string, + blockedId: string, + existing: readonly Edge[], +): { ok: true } | { ok: false; reason: string } { + if (blockerId === blockedId) { + return { ok: false, reason: "A task cannot block itself." }; + } + if (existing.some((e) => e.blocker_id === blockerId && e.blocked_id === blockedId)) { + return { ok: false, reason: "That dependency is already there." }; + } + return { ok: true }; +} diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx index 838cc3d1..b5482de6 100644 --- a/workbench/control_plane/src/app/projects/page.tsx +++ b/workbench/control_plane/src/app/projects/page.tsx @@ -35,11 +35,13 @@ import { MyWork } from "./components/MyWork"; import { NotificationBell } from "./components/NotificationBell"; import { ProjectTree } from "./components/ProjectTree"; import { CalendarView } from "./components/CalendarView"; +import { TimelineView } from "./components/TimelineView"; import { TaskBoard } from "./components/TaskBoard"; import { TaskList } from "./components/TaskList"; import { TaskPanel } from "./components/TaskPanel"; import { SAVED_VIEW_POSITION, orderBearingView, type planDrop } from "./lib/board"; import { calendarWindow, dayKey, monthGrid, shiftMonth } from "./lib/calendar"; +import type { Edge } from "./lib/timeline"; import { EMPTY_FILTERS, type Filters, @@ -61,11 +63,16 @@ import { import { fetchAccess } from "@/lib/access"; import { filterByCenter, flatten } from "./lib/tree"; -type ViewMode = "board" | "list" | "calendar"; +type ViewMode = "board" | "list" | "calendar" | "timeline"; /** An empty calendar window — the shape before anything has been fetched, and * the shape after a failure, so the view never renders a stale month. */ -const NO_MONTH = { rows: [] as TaskRow[], undated: 0, truncated: false }; +const NO_MONTH = { + rows: [] as TaskRow[], + links: [] as Edge[], + undated: 0, + truncated: false, +}; function ProjectsWorkspace() { const searchParams = useSearchParams(); @@ -238,10 +245,14 @@ function ProjectsWorkspace() { include_subtree: true, from, to, + // WS-27t — only the timeline draws arrows, and the calendar would pay + // for a query it never reads. + include_links: mode === "timeline", ...toQuery(filters), }); setMonth({ rows: res.rows, + links: res.links, undated: res.undated, truncated: res.truncated, }); @@ -251,10 +262,12 @@ function ProjectsWorkspace() { // heading is a calendar confidently showing the wrong dates. setMonth(NO_MONTH); } - }, [selected, grid, filters]); + }, [selected, grid, filters, mode]); useEffect(() => { - if (mode === "calendar") void loadMonth(); + // Both date views read the same window endpoint — the WINDOW is the + // resource, and calendar and timeline are two renderings of it. + if (mode === "calendar" || mode === "timeline") void loadMonth(); }, [mode, loadMonth]); useEffect(() => { @@ -524,6 +537,27 @@ function ProjectsWorkspace() { * has already refused a no-op, so this never posts an activity saying a task * moved to where it already was. */ + /** + * WS-27t — a dependency drawn on the timeline. + * + * The SAME endpoint the task panel's dropdown posts to, so the cycle guard, + * the activity row and the permission check are one implementation. The + * refusal shown is the gateway's own message — `assert_no_block_cycle` + * explains a loop better than anything this component could invent, and a + * second wording would be a second rule to keep in step. + * + * **Nothing is rescheduled (D-PM-12).** Creating the link may make the arrow + * red; that is the whole intended effect. + */ + async function linkTasks(blockerId: string, blockedId: string) { + try { + await projectsApi.createLink(blockerId, blockedId, "blocks"); + } catch (err) { + setError(String((err as Error).message)); + } + await loadMonth(); + } + async function moveTask(task: TaskRow, patch: Record) { setMonth((current) => ({ ...current, @@ -689,7 +723,7 @@ function ProjectsWorkspace() {
- {(["board", "list", "calendar"] as ViewMode[]).map((m) => ( + {(["board", "list", "calendar", "timeline"] as ViewMode[]).map((m) => ( + + ))} + + + {view.kind === "results" && view.truncated ? ( +

+ More matches than fit — add a word to narrow it. +

+ ) : null} +
+ + + ); +} diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts index a376970c..e8c7dbf9 100644 --- a/workbench/control_plane/src/app/projects/lib/api.ts +++ b/workbench/control_plane/src/app/projects/lib/api.ts @@ -221,6 +221,21 @@ export const projectsApi = { }>(`calendar?${qs.toString()}`); }, + /** + * WS-27r — ranked hits across every project the caller can see. + * + * Not `tasks?q=`: that endpoint is paginated and its ordering is a column + * allowlist, neither of which a search box wants. `query` is echoed back so + * a slow response to an earlier keystroke can be recognised and dropped. + */ + search: (q: string) => + call<{ + rows: import("./search").Hit[]; + total: number; + truncated: boolean; + query: string; + }>(`search?q=${encodeURIComponent(q)}`), + task: (taskId: string) => call(`tasks/${taskId}`), timeline: (taskId: string) => diff --git a/workbench/control_plane/src/app/projects/lib/search.test.ts b/workbench/control_plane/src/app/projects/lib/search.test.ts new file mode 100644 index 00000000..b85d1dc7 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/search.test.ts @@ -0,0 +1,272 @@ +/** + * WS-27r — the palette's logic. + * + * Every claim here is one that only shows up under real typing speed or a slow + * connection, which is exactly why they are asserted rather than clicked: + * + * * **"no results" may only be claimed once.** Shown while a request is in + * flight, it flashes between every keystroke and its answer — the single most + * common bug in hand-rolled search UIs, and it reads as the search being + * broken rather than slow. + * * **a stale response must not win.** "par" and "parser" are two requests with + * no ordering guarantee; a slow "par" landing last replaces the right answers + * with old ones and the list changes without a keystroke. + * * **arrow keys must not reach the input**, or the caret jumps while the + * selection moves — two effects from one key. + * * **a modified key is not a palette action.** `Cmd+Left` is "go to line + * start", and stealing it breaks editing inside the palette's own box. + * * **the highlight needle is escaped.** Searching `a+b` would otherwise throw + * a regex syntax error — the browser twin of the LIKE defect this ticket + * fixed on the server. + */ + +import { describe, expect, it } from "vitest"; + +import { + type Hit, + MIN_QUERY, + highlight, + hitContext, + isCurrent, + isOpenShortcut, + moveSelection, + paletteKey, + paletteState, +} from "./search"; + +const hit = (over: Partial = {}): Hit => ({ + id: "t1", + title: "Refactor the parser", + project_id: "p1", + project_name: "Ops", + rank: 1, + ...over, +}); + +const state = (over: Partial[0]> = {}) => + paletteState({ + query: "parser", + loading: false, + hits: null, + truncated: false, + error: null, + ...over, + }); + +// ── paletteState ──────────────────────────────────────────────────────────── + +describe("paletteState", () => { + it("is idle until the query is long enough", () => { + expect(state({ query: "" }).kind).toBe("idle"); + expect(state({ query: "p" }).kind).toBe("idle"); + expect(state({ query: " p " }).kind).toBe("idle"); + }); + + it("leaves idle at exactly the server's minimum", () => { + expect(MIN_QUERY).toBe(2); + expect(state({ query: "pa", hits: [] }).kind).toBe("empty"); + }); + + it("never claims 'no results' while a request is in flight", () => { + // ⚠️ THE palette bug. An empty state flashing between every keystroke and + // its answer reads as broken rather than slow. + expect(state({ loading: true, hits: [] }).kind).toBe("searching"); + expect(state({ loading: true, hits: null }).kind).toBe("searching"); + }); + + it("keeps the previous results on screen while the next load runs", () => { + // ⚠️ Blanking and re-filling under the cursor makes the list unusable at + // typing speed, and moves whatever row was selected. + const shown = state({ loading: true, hits: [hit()] }); + expect(shown.kind).toBe("results"); + expect(shown.kind === "results" && shown.hits).toHaveLength(1); + }); + + it("says nothing at all before the first response arrives", () => { + expect(state({ hits: null }).kind).toBe("typing"); + }); + + it("claims empty only once a real answer has come back empty", () => { + expect(state({ hits: [] }).kind).toBe("empty"); + }); + + it("carries truncation through so the view can admit it", () => { + const shown = state({ hits: [hit()], truncated: true }); + expect(shown.kind === "results" && shown.truncated).toBe(true); + }); + + it("shows an error over everything else, including a stale result set", () => { + // An error under a list of old hits is an error nobody sees. + expect(state({ hits: [hit()], error: "Request failed (500)" })).toEqual({ + kind: "error", + message: "Request failed (500)", + }); + }); +}); + +// ── isCurrent ─────────────────────────────────────────────────────────────── + +describe("isCurrent", () => { + it("accepts the response to what is in the box now", () => { + expect(isCurrent("parser", "parser")).toBe(true); + }); + + it("rejects a slow response to an earlier query", () => { + // ⚠️ Two requests, no ordering guarantee. A slow "par" landing after a fast + // "parser" would replace the right answers with stale ones. + expect(isCurrent("par", "parser")).toBe(false); + }); + + it("ignores whitespace on either side, as the server does", () => { + expect(isCurrent("parser", " parser ")).toBe(true); + }); +}); + +// ── moveSelection ─────────────────────────────────────────────────────────── + +describe("moveSelection", () => { + it("steps down and up", () => { + expect(moveSelection(0, 1, 3)).toBe(1); + expect(moveSelection(2, -1, 3)).toBe(1); + }); + + it("wraps at both ends", () => { + // Palettes are used without looking; the hands expect the wrap. + expect(moveSelection(2, 1, 3)).toBe(0); + expect(moveSelection(0, -1, 3)).toBe(2); + }); + + it("clamps an index left pointing past a list that shrank", () => { + // ⚠️ Results change on every keystroke. An index past the end is an Enter + // that opens nothing. + expect(moveSelection(9, 1, 3)).toBe(0); + expect(moveSelection(9, 0, 3)).toBe(2); + }); + + it("survives an empty list without going negative", () => { + expect(moveSelection(0, -1, 0)).toBe(0); + expect(moveSelection(3, 1, 0)).toBe(0); + }); + + it("handles a single result, where every move is a no-op", () => { + expect(moveSelection(0, 1, 1)).toBe(0); + expect(moveSelection(0, -1, 1)).toBe(0); + }); +}); + +// ── keys ──────────────────────────────────────────────────────────────────── + +describe("paletteKey", () => { + it("claims the arrows, Enter and Escape", () => { + // ⚠️ Left to the browser, the arrows move the text caret to the start or + // end of the query — the selection moves AND the cursor jumps. + expect(paletteKey({ key: "ArrowDown" })).toBe("down"); + expect(paletteKey({ key: "ArrowUp" })).toBe("up"); + expect(paletteKey({ key: "Enter" })).toBe("open"); + expect(paletteKey({ key: "Escape" })).toBe("close"); + }); + + it("leaves ordinary typing alone", () => { + expect(paletteKey({ key: "a" })).toBeNull(); + expect(paletteKey({ key: "ArrowLeft" })).toBeNull(); + expect(paletteKey({ key: "Backspace" })).toBeNull(); + }); + + it("is not an action when a modifier is held", () => { + // ⚠️ `Cmd+Left` is "go to line start". Stealing it breaks editing inside + // the palette's own input. + expect(paletteKey({ key: "ArrowDown", metaKey: true })).toBeNull(); + expect(paletteKey({ key: "Enter", ctrlKey: true })).toBeNull(); + expect(paletteKey({ key: "ArrowUp", altKey: true })).toBeNull(); + }); +}); + +describe("isOpenShortcut", () => { + it("opens on Cmd-K and Ctrl-K", () => { + expect(isOpenShortcut({ key: "k", metaKey: true })).toBe(true); + expect(isOpenShortcut({ key: "k", ctrlKey: true })).toBe(true); + }); + + it("survives caps lock", () => { + expect(isOpenShortcut({ key: "K", metaKey: true })).toBe(true); + }); + + it("does not fire on a bare k, which is a letter somebody typed", () => { + expect(isOpenShortcut({ key: "k" })).toBe(false); + }); + + it("does not fire on another modified letter", () => { + expect(isOpenShortcut({ key: "j", metaKey: true })).toBe(false); + }); +}); + +// ── highlight ─────────────────────────────────────────────────────────────── + +describe("highlight", () => { + it("splits a title around the match", () => { + expect(highlight("Refactor the parser", "parser")).toEqual([ + { text: "Refactor the ", match: false }, + { text: "parser", match: true }, + ]); + }); + + it("matches case-insensitively, as the query itself does", () => { + expect(highlight("Parser rewrite", "parser")[0]).toEqual({ + text: "Parser", + match: true, + }); + }); + + it("marks every occurrence, not only the first", () => { + const parts = highlight("parser calls parser", "parser"); + expect(parts.filter((p) => p.match)).toHaveLength(2); + }); + + it("escapes the needle before it becomes a regex", () => { + // ⚠️ The browser twin of the LIKE-metacharacter defect. `a+b` unescaped is + // a quantifier, and `(draft)` is an unbalanced group that THROWS — the + // palette would go blank on a perfectly ordinary query. + expect(() => highlight("a+b is fine", "a+b")).not.toThrow(); + expect(highlight("a+b is fine", "a+b")[0]).toEqual({ + text: "a+b", + match: true, + }); + expect(() => highlight("the (draft) copy", "(draft)")).not.toThrow(); + }); + + it("returns the whole string when the query is empty", () => { + expect(highlight("Anything", "")).toEqual([ + { text: "Anything", match: false }, + ]); + }); + + it("never loses or duplicates a character", () => { + // The property that matters: highlighting is presentation, so the text + // must survive it exactly. + for (const [text, query] of [ + ["Refactor the parser", "parser"], + ["parser", "parser"], + ["nothing here", "zzz"], + ["a.b.c", "."], + ] as const) { + expect(highlight(text, query).map((p) => p.text).join("")).toBe(text); + } + }); +}); + +// ── hitContext ────────────────────────────────────────────────────────────── + +describe("hitContext", () => { + it("names the project and the number", () => { + expect(hitContext(hit({ task_number: 42 }))).toBe("Ops · #42"); + }); + + it("drops a part it does not have rather than leaving a dangling dot", () => { + expect(hitContext(hit({ task_number: null }))).toBe("Ops"); + expect(hitContext(hit({ project_name: null, task_number: 7 }))).toBe("#7"); + }); + + it("is empty rather than punctuation when it knows nothing", () => { + expect(hitContext(hit({ project_name: null, task_number: null }))).toBe(""); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/search.ts b/workbench/control_plane/src/app/projects/lib/search.ts new file mode 100644 index 00000000..5418678d --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/search.ts @@ -0,0 +1,174 @@ +/** + * Projects · the search palette's logic (WS-27r). + * + * A palette is a keyboard instrument, and every one of its rules is the kind + * that is wrong-but-plausible: which keystroke reaches the list rather than the + * input, what a stale response does when it arrives after a newer one, and what + * "no results" means while a request is still in flight. + * + * Kept out of the component so those can be asserted rather than clicked. + */ + +/** One hit, exactly as `GET /projects/search` returns it. */ +export interface Hit { + id: string; + title: string; + task_number?: number | null; + project_id: string; + project_name?: string | null; + status_name?: string | null; + category?: string | null; + due_at?: string | null; + completed_at?: string | null; + rank: number; +} + +/** Mirrors the gateway's `MIN_QUERY`. Below it the endpoint answers empty. */ +export const MIN_QUERY = 2; + +/** How long to wait after the last keystroke before asking. */ +export const DEBOUNCE_MS = 180; + +export type PaletteState = + | { kind: "idle" } + | { kind: "typing" } + | { kind: "searching" } + | { kind: "results"; hits: Hit[]; truncated: boolean } + | { kind: "empty" } + | { kind: "error"; message: string }; + +/** + * What the palette should show, given what it knows. + * + * **"No results" is a claim, and it may only be made once.** While a request + * is in flight the palette says nothing rather than "no results found" — + * flashing an empty state between every keystroke and its answer is how a + * palette comes to look broken on a slow connection, and it is the single most + * common bug in hand-rolled search UIs. + */ +export function paletteState(input: { + query: string; + loading: boolean; + hits: Hit[] | null; + truncated: boolean; + error: string | null; +}): PaletteState { + if (input.error) return { kind: "error", message: input.error }; + if (input.query.trim().length < MIN_QUERY) return { kind: "idle" }; + if (input.loading) { + // A previous answer stays on screen while the next one loads, so the list + // does not blank and re-fill under the cursor on every keystroke. + return input.hits && input.hits.length > 0 + ? { kind: "results", hits: input.hits, truncated: input.truncated } + : { kind: "searching" }; + } + if (input.hits === null) return { kind: "typing" }; + if (input.hits.length === 0) return { kind: "empty" }; + return { kind: "results", hits: input.hits, truncated: input.truncated }; +} + +/** + * Is this response still the one we want? + * + * **The out-of-order trap.** Typing "par" then "parser" issues two requests, + * and there is no rule saying the first finishes first — a slow "par" landing + * after a fast "parser" replaces the right answers with stale ones, and the + * user sees the list change without touching the keyboard. Comparing the + * response's own echoed query against what is in the box now is the cheapest + * correct fix, and it needs no request ids because the server echoes `query`. + */ +export function isCurrent(responseQuery: string, liveQuery: string): boolean { + return responseQuery.trim() === liveQuery.trim(); +} + +/** + * Where the selection moves. + * + * **Wraps at both ends**, because a palette is used without looking at it: Down + * from the last row goes to the first, and Up from the first goes to the last, + * which is how every other palette behaves and therefore what the hands expect. + * Clamped to a valid index whenever the list shrinks under the cursor — the + * results change on every keystroke, and an index left pointing past the end is + * an Enter that opens nothing. + */ +export function moveSelection( + current: number, + delta: number, + length: number, +): number { + if (length <= 0) return 0; + const from = Math.min(Math.max(current, 0), length - 1); + return (((from + delta) % length) + length) % length; +} + +/** The keys the palette consumes, and what they mean. */ +export type PaletteAction = "up" | "down" | "open" | "close" | null; + +/** + * Which palette action a keystroke is, if any. + * + * **Arrow keys must not reach the input.** Left to the browser they move the + * text caret to the start or end of the query, so the selection appears to move + * while the cursor jumps — two effects from one key. + * + * A key with a modifier held is NOT an action: `Cmd+Left` is "go to line start" + * and stealing it breaks text editing inside the very box the palette is built + * around. + */ +export function paletteKey(event: { + key: string; + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; +}): PaletteAction { + if (event.metaKey || event.ctrlKey || event.altKey) return null; + if (event.key === "ArrowDown") return "down"; + if (event.key === "ArrowUp") return "up"; + if (event.key === "Enter") return "open"; + if (event.key === "Escape") return "close"; + return null; +} + +/** Does this keystroke open the palette? ⌘K or Ctrl-K, from anywhere. */ +export function isOpenShortcut(event: { + key: string; + metaKey?: boolean; + ctrlKey?: boolean; +}): boolean { + return (event.key === "k" || event.key === "K") && + Boolean(event.metaKey || event.ctrlKey); +} + +/** + * The parts of a title around every match, for highlighting. + * + * Case-insensitive to match the query's own ILIKE, and the needle is escaped + * before it becomes a regex — a user searching for `a+b` or `(draft)` would + * otherwise blow up the palette with a syntax error, which is the browser-side + * twin of the LIKE-metacharacter defect this ticket fixed on the server. + */ +export function highlight( + text: string, + query: string, +): { text: string; match: boolean }[] { + const needle = query.trim(); + if (!needle) return [{ text, match: false }]; + const pattern = new RegExp( + `(${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, + "ig", + ); + return text + .split(pattern) + .filter((part) => part !== "") + .map((part) => ({ + text: part, + match: part.toLowerCase() === needle.toLowerCase(), + })); +} + +/** "Ops · #42", the one line that says where a hit lives. */ +export function hitContext(hit: Hit): string { + const parts = [hit.project_name, hit.task_number ? `#${hit.task_number}` : null] + .filter(Boolean); + return parts.join(" · "); +} diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx index b5482de6..04ac7413 100644 --- a/workbench/control_plane/src/app/projects/page.tsx +++ b/workbench/control_plane/src/app/projects/page.tsx @@ -35,12 +35,14 @@ import { MyWork } from "./components/MyWork"; import { NotificationBell } from "./components/NotificationBell"; import { ProjectTree } from "./components/ProjectTree"; import { CalendarView } from "./components/CalendarView"; +import { SearchPalette } from "./components/SearchPalette"; import { TimelineView } from "./components/TimelineView"; import { TaskBoard } from "./components/TaskBoard"; import { TaskList } from "./components/TaskList"; import { TaskPanel } from "./components/TaskPanel"; import { SAVED_VIEW_POSITION, orderBearingView, type planDrop } from "./lib/board"; import { calendarWindow, dayKey, monthGrid, shiftMonth } from "./lib/calendar"; +import { isOpenShortcut } from "./lib/search"; import type { Edge } from "./lib/timeline"; import { EMPTY_FILTERS, @@ -133,6 +135,10 @@ function ProjectsWorkspace() { // WS-27q — the calendar is a WINDOW, not the paged task list, so it holds // its own rows. Sharing `tasks` would mean either paginating the calendar // (a month with silently missing days) or unpaginating the board. + // WS-27r — the search palette. Held at the page rather than in a view, + // because the whole point is that it works from wherever you already are. + const [searching, setSearching] = useState(false); + const [monthAnchor, setMonthAnchor] = useState(() => new Date()); const [month, setMonth] = useState(NO_MONTH); @@ -141,6 +147,19 @@ function ProjectsWorkspace() { const [bulkBusy, setBulkBusy] = useState(false); const [bulkNotice, setBulkNotice] = useState(null); + useEffect(() => { + // ⌘K from anywhere in Projects. `preventDefault` because the browser's own + // ⌘K is the address bar's search on some, and losing the app to it is a + // shortcut that works once. + function onKey(event: KeyboardEvent) { + if (!isOpenShortcut(event)) return; + event.preventDefault(); + setSearching(true); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + useEffect(() => { // Only for the "Mine" toggle. `fetchAccess` never throws, and an empty // address disables the button rather than filtering on nobody. @@ -720,6 +739,15 @@ function ProjectsWorkspace() { Tags ) : null} +
@@ -858,6 +886,12 @@ function ProjectsWorkspace() {
+ setSearching(false)} + onOpenTask={(id) => void openTaskById(id)} + /> + {openTask ? ( , "size /** Lucide icon name shown inside the field's leading edge. */ icon?: string; className?: string; + /** + * Forwarded to the underlying ``. Declared rather than inherited + * because `InputHTMLAttributes` does not carry `ref`: on React 19 a function + * component receives it as an ordinary prop, so the spread below is all the + * plumbing needed — no `forwardRef` wrapper. + * + * Added for WS-27r's search palette, which has to focus its field the moment + * it opens; a palette you have to click into is a palette you stop using. + */ + ref?: React.Ref; }; export function Input({ inputSize = "md", icon, className = "", ...rest }: InputProps) { From ccb762a815520533be98845d86852a0ab3a988d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 14:39:57 +0000 Subject: [PATCH 08/22] =?UTF-8?q?docs(WS-29):=20mint=20multi-tenancy=20?= =?UTF-8?q?=E2=80=94=20measured=20state,=20the=20fork,=20and=20a=20ratchet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner is planning migrations for a multi-tenant CommandCenter and asked that current work take it into account. Measured the blast radius rather than guessing, and it changes what should happen next. MEASURED, off the migration tree and checked against a live Postgres 16: 143 app tables, SIX carry organization_id (app_user, crm_activities, crm_contacts, crm_deals, org_group, org_role), 137 carry none — including all seventeen pm_*. An `organization` table has existed since migration 130 with one seeded row (slug='default') and app_user.organization_id. Tenancy was started and never carried past access control and the CRM; org_settings says so in its own comment. So this is not a Projects problem. WS-27 is 17 of the 137, alongside every gtd_*, email_*, wa_*, workflow*, app*, chat_* table. The number that decides the cost: app_user.email is globally UNIQUE, so today one person = one organization structurally. D-MT-1 asks whether that stays true. If yes, the tenant is derivable from X-User-Email and the identity seam the whole platform is built on does not change shape. If no, UNIQUE(email) becomes UNIQUE(organization_id, email), every request needs a tenant discriminator, and bare emails stop identifying a person — which reopens D-PM-4's grant and assignee vocabulary. That is a product question about how the SaaS is sold, so it is owner-answer; the recommendation on record is (a) for v1, relaxable later behind an org-switcher. Projects is cheaper to retrofit than its size suggests: 128 FROM/JOIN references to pm_* across 16 modules, but ONE closure query. So it is a column on 17 tables, a predicate in one query, and one line in Visibility — contained, and contained only until real data lands. Which is why this blocks something already queued: the production ClickUp import (§6 gate (a)) is not wrong, it is EARLY. Running it now writes a real workspace into 17 unscoped tables and turns a one-line default on empty tables into a backfill on live rows. Registered on the gate itself, since that is where somebody will read it. Ships one piece of code — tests/unit/test_tenancy_boundary.py — a ratchet on the conformance.test.ts model: the 137 are frozen, a NEW table without organization_id fails, and a baselined table that GAINED one fails until it leaves the baseline so the figure never quietly becomes fiction. It reads ALTER TABLE ... ADD COLUMN too, which is how app_user got its key and which a CREATE-only scan reports as unscoped — it did, in the first version, and the parser was checked against the live schema before this was written. Its job is not to demand the retrofit; it is to stop the number growing while D-MT-1 is answered. Verified: the ratchet bites on all three cases (new unscoped table, fixed baselined table, stale baseline entry) and passes a new correctly-scoped table — which the first version wrongly failed, so that assertion was replaced with one that checks something real. 661 backend tests, ruff clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/specs/multi_tenancy.md | 182 +++++++++++++++++ ai-company-brain/work_plan.md | 8 + tests/unit/test_tenancy_boundary.py | 257 ++++++++++++++++++++++++ 3 files changed, 447 insertions(+) create mode 100644 ai-company-brain/specs/multi_tenancy.md create mode 100644 tests/unit/test_tenancy_boundary.py diff --git a/ai-company-brain/specs/multi_tenancy.md b/ai-company-brain/specs/multi_tenancy.md new file mode 100644 index 00000000..cd93daee --- /dev/null +++ b/ai-company-brain/specs/multi_tenancy.md @@ -0,0 +1,182 @@ +# Multi-tenancy — isolating organizations in CommandCenter + +> **Minted 2026-08-08** on the owner's notice that *"we are also going to be doing migrations +> for a multi-tenant system so that multiple organizations can use the command center in an +> isolated way."* +> +> **Everything below §1 is measured, not recalled** — read off the migration tree and checked +> against a live Postgres 16 with the full set applied. Where this document gives a number, +> `tests/unit/test_tenancy_boundary.py` recomputes it on every run. + +--- + +## 1. The measured state + +CommandCenter is **single-tenant with the beginnings of a tenant boundary already in place**, +which is a better starting position than it sounds and a worse one than it looks. + +| | | +|---|---| +| App tables defined in migrations | **143** (plus `LiteLLM_*`, vendored, not ours) | +| Carrying `organization_id` | **6** | +| Carrying none | **137** | +| `pm_*` tables (Projects, WS-27) | 17 — **0 scoped** | + +The six that are scoped: `app_user`, `crm_activities`, `crm_contacts`, `crm_deals`, +`org_group`, `org_role`. + +**An `organization` table already exists** (migration 130) with `slug`, `display_name`, +`domain`, `settings`, and exactly one seeded row — `slug='default'`. `app_user` gained +`organization_id` in the same migration. So the spine of a tenant model is there; it was +simply never carried past the access-control system and the CRM. + +**This is not a Projects problem.** WS-27 is 17 of the 137, and the majority of the tree is in +the same position: every `gtd_*`, `email_*`, `wa_*`, `workflow*`, `app*`, `chat_*` table, and +— tellingly — `org_settings`, `org_role_permission`, `user_role` and `org_group_member`. +`org_settings` says so in its own comment: *"there is no per-tenant key namespace because this +deployment is one organisation."* That comment is about to stop being true. + +### 1.1 The one number that decides the cost + +`app_user.email` is **globally `UNIQUE`** (`app_user_email_key`). Today a person belongs to +exactly one organization, structurally. Whether that stays true is **D-MT-1**, and it is the +decision the whole retrofit hangs off. + +### 1.2 Why Projects is cheaper to retrofit than its size suggests + +128 `FROM`/`JOIN` references to `pm_*` tables across 16 modules — but they do not each scope +themselves. There is **one closure query**, `_VISIBLE_PROJECTS_SQL`, reached through +`resolve_visibility` (60 call sites), `load_visible_project` (31), `load_visible_task` (26) and +`task_visibility_clause` (6). Every read in the app funnels through it. + +So the Projects retrofit is: **a column on 17 tables, a predicate in one query, and one line in +the `Visibility` resolver.** That is contained. It is contained *because the app was built with +a single visibility seam*, and it stops being contained the moment real data lands in those +tables. + +--- + +## 2. What this means for the ClickUp import — read this first + +🔴 **Do not run `POST /projects/import/clickup` against production until the `pm_*` tenant key +lands.** This is the one place where the multi-tenant plan collides with work already queued. + +The import is an owner gate (`work_plan.md` §6 (a)) and is the next thing WS-27 wants. Running +it now writes a real ClickUp workspace — hundreds of tasks, their activities, attachments and +grants — into 17 tables with no tenant column. Adding the column afterwards means a backfill +and an `ALTER` on live rows instead of a one-line default on empty ones. + +**The cost of waiting is a few days. The cost of not waiting is paid once per table, forever.** + +--- + +## 3. The decisions + +### D-MT-1 — Can one person belong to more than one organization? + +`DECISION — OWNER-ANSWER REQUIRED.` Everything else in this document is downstream of it, and +it is a product question about how the SaaS is sold rather than a technical one. + +* **(a) One person, one organization.** `app_user.email` stays globally unique. A request's + tenant is *derived* from `X-User-Email`, so the identity seam every app already reads does + not change shape — `resolve_visibility` grows one lookup and every query inherits the answer. + **Cost:** a consultant working with two customer organizations needs two accounts with two + email addresses. For an internal tool becoming a product this is normal; for an agency + product it is a dealbreaker. +* **(b) One person, many organizations.** `UNIQUE(email)` becomes `UNIQUE(organization_id, + email)`, and identity stops being resolvable from the email alone. **Every request needs a + tenant discriminator** — a subdomain, a path segment, or a selected-org cookie — and that + touches the auth seam of *every* app, not just Projects. It also reopens settled ground: + `pm_project_grants.subject` and `pm_task_assignees.assignee` are bare emails (D-PM-4), and + under (b) a bare email no longer identifies a person. + +**Agent's recommendation: (a) for v1**, because it preserves the `X-User-Email` seam the whole +platform is built on and can be relaxed later behind an org-switcher, whereas (b) is a change +to identity itself and cannot be deferred once accounts exist. **This is a recommendation, not +a proposal** — if the intended customers are agencies or consultancies, (b) is right and it is +much cheaper to decide that now than after the first tenant onboards. + +### D-MT-2 — Where is isolation *enforced*? + +`DECISION (agent-proposed, owner may overrule) — OPEN.` + +* **(a) Row-level security.** Postgres RLS with `organization_id = current_setting('app.org')`, + set per connection. The database refuses cross-tenant reads whether or not the application + remembers to filter. **Cost:** every connection must set the GUC — including the ingestion + workers, the broker, and the migration runner — and a missed `SET` fails closed, which is + the right direction but is an outage rather than a leak. +* **(b) An application predicate**, exactly as `task_visibility_clause` works today. + **Cost:** correctness rests on 143 tables' worth of query authors never forgetting, which is + the discipline that produced 137 unscoped tables in the first place. +* **(c) A schema per tenant.** Strong isolation, no predicate anywhere. **Cost:** migrations + run N times, and the connection pool multiplies. At single-digit tenant counts this is fine + and at three digits it is a second full-time problem. + +**Proposed: (a) RLS, with (b) kept where it already exists.** RLS is the only option where the +*absence* of code is safe rather than a leak — and given the measured 137, absence of code is +the failure mode this system actually has. `task_visibility_clause` stays: RLS decides *which +tenant*, grants decide *which projects within it*, and those are different questions. + +### D-MT-3 — `organization_id` on the row, or reachable through a parent? + +`DECISION (agent-proposed, owner may overrule) — OPEN.` + +`pm_tasks` already has `root_project_id` denormalised precisely so scope checks need no +recursive walk (migration 146). The same argument applies one level up: **carry +`organization_id` on every tenant-owned table**, even where it is derivable. + +**Rejected:** deriving it through the parent chain. RLS policies cannot afford a join, a +derived key cannot be indexed usefully, and "derivable" stops being true the moment a row's +parent is nullable — which `pm_tasks.parent_task_id` already is (`ON DELETE SET NULL`). +**Cost:** the column must be kept true on write, which is one more thing an `INSERT` can get +wrong; a `CHECK` against the parent's value is the cheap guard. + +--- + +## 4. The ratchet, in place now + +`tests/unit/test_tenancy_boundary.py` freezes the 137 and fails any **new** table without +`organization_id`, on the model of the frontend's `conformance.test.ts`: + +* a table not in the baseline must carry a tenant key; +* a baselined table may stay as it is; +* a baselined table that *gained* one fails until it is removed from the baseline, so the + figure never quietly becomes fiction. + +It reads the migrations — including `ALTER TABLE … ADD COLUMN organization_id`, which is how +`app_user` got its key and which a `CREATE TABLE`-only scan misses — and its output was +checked against a live Postgres before it was written. Its purpose is **not** to demand the +retrofit. It is to stop the number growing while D-MT-1 is answered, because every table added +between now and then is another backfill. + +--- + +## 5. Proposed sequence + +| | Ticket | Depends on | +|---|---|---| +| 1 | **WS-29a** — answer D-MT-1; `organization_id` on the 17 `pm_*` tables while they are still empty | D-MT-1 | +| 2 | **WS-29b** — the tenant predicate in `_VISIBLE_PROJECTS_SQL` + `Visibility`; the `subject='org'` literal becomes org-relative | WS-29a | +| 3 | **WS-29c** — RLS policies and the connection-level GUC, behind a flag, off | D-MT-2 | +| 4 | **WS-29d** — the remaining 120 tables, by family, largest blast radius first | WS-29c | +| — | **WS-27g's ClickUp import** | **after WS-29a** | + +**WS-29a is the only urgent one**, and only because of the import. The rest can proceed at +whatever pace the product needs. + +--- + +## 6. What is already right, and should not be redone + +Worth stating so the retrofit does not churn it: + +* **`organization` exists and is referenced correctly** where it is used at all — `app_user`, + `org_group`, `org_role` all `REFERENCES organization(id) ON DELETE CASCADE`. +* **Projects has one visibility seam.** That is the property making its retrofit a day rather + than a month; it should survive intact, with the tenant predicate composed *above* the grant + closure rather than tangled into it. +* **The grant vocabulary (`email | group: | org`) is tenant-shaped already** — except + that the `org` literal means "everybody", and under multi-tenancy it must mean "everybody in + *this* organization". That is one clause, in one query, and it is the single most dangerous + line in the retrofit: today it is correct, and after the first second tenant onboards it is a + cross-tenant leak. diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index 6e6737c4..aaf054d4 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -148,6 +148,7 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`. | WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. | | WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. | | **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT + DEPLOYED** · ✅ **d read half BUILT + DEPLOYED** · ✅ **D2 = d-email BUILT 2026-08-07** (branch `ws-26d-email-timeline`, merged) · 🟢 **d-autolead, d-write dispatchable** · ✅ **D1 = f BUILT 2026-08-07 (branch `ws-26f-pipeline-truth`, NOT run against prod)** · ✅ **D3 = g BUILT 2026-08-07 (branch `ws-26g-reports`, no migration)** · 🟢 **DEMO CRITICAL PATH (owner-directed 2026-08-07, spec §9.0): ~~D1 f~~ (∥ D2 d-email) → ~~D3 g~~ → D4 d-write → D5 d-autolead** · 🟡 **h/i/e deferred past the demo; i spec-thin** | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **migration 144 applied on prod and `/crm` live as of 2026-08-06**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Measured 2026-08-06: mig 145 is applied on prod and the BACKFILL HAS RUN — 737 orgs / 1,189 contacts / 1,516 leads / 551 deals / 1,909 notes, zero dirty rows, zero unmatched owners; the §7.1 pre-flip curl confirmed the tenant honors RFC-1123 `If-Modified-Since` (304). The PUSH direction has still never run: `CRM_ZOHO_SYNC` ships OFF, nothing has ever written the live Zoho tenant, and enabling the flag or hand-running a push cycle against prod stays OWNER-GATE §6.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Deployed:** migrations 144 and 145 are applied on prod as of 2026-08-06 and `/crm` is live, so live rendering, drag persistence and deep links are owner-verifiable now) · **d** integrations — **audited 2026-08-06 GO-NARROWED and the narrowed slice is BUILT** (branch `ws-26d-agent-crm`): `apps/agents/agent-crm/` (`crm-assistant`, MAF, four READ tools over the existing `/crm` routes carrying the caller's `X-User-Email`, read-only enforced at the transport by a GET-only method allowlist) registered in `_KNOWN_AGENTS` + `_AGENT_REGISTRY` + `agent_registry.json`, plus `"crm"` added to the WhatsApp `_KNOWN_SYSTEMS` allowlist **parse-only** (nothing writes `wa_contacts.entity_ref`, the `crm` context block stays `None`, both pinned by test). **The three held-back items are now DISPATCHABLE — their doc blockers (B3/B4/B5/B7) were closed 2026-08-06 in `crm_app.md` §9.1-§9.3, every anchor read off `origin/main` rather than recalled:** **WS-26d-email** (the timeline join is CALLER-scoped, never record-scoped — it reuses the email app's `_account_scope` predicate, copied into `routes/crm/` rather than imported per D-CRM-4, joins by thread not message, inbound `from_address` only, and needs a new address index at the next free migration number) · **WS-26d-autolead** (hook = `routes/email/scheduler_hooks.py::process_new_mail`, the one seam scheduler+manual+webhook all funnel through; the per-message rules loop was considered and REJECTED because a classifier outage there double-fires and history backfills never reach it; unknown-sender test mirrors `_maybe_block_cold`, colleague suppression via `is_own_mail`) · **WS-26d-write** (`request_confirmation` at the top of each tool, fail-closed, no `non_interactive_default="approve"`; `@_annotate_risk` is the shared annotation convention and is NOT enforcement; the Action Broker covers the Zoho push, not the native write — the two fail in OPPOSITE directions and are not interchangeable). The push-queue question is CLOSED — **D-CRM-9 (owner, 2026-08-06): agent-originated writes queue for Zoho exactly like human ones** (🟡 remainder; the flip stays OWNER-GATE) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. **Pipeline blueprint added 2026-08-07 (spec §5.1) after the owner's first live board session found lanes out of order and imported stages at 0% probability — root cause: the importer appends unseen Zoho stages past the seeds at probability 0, and 144's seeds renamed Zoho's defaults so name-match missed them; the admin API that could fix it is headless.** New tickets: **f** pipeline truth + settings UI — **BUILT 2026-08-07** (branch `ws-26f-pipeline-truth`, **no migration**: 144 already carried `position`, `probability`, `type`, `closed_at` and `expected_close_date`). `routes/crm/stage_metadata.py` = `POST /crm/import/zoho/stages`, floor `admin:access:manage`, **dry-run by default** and `?apply=true` to write; **>1 pipeline STOPS before the DB is opened** (D-CRM-11); f4's `closed_at` proxy is a direct UPDATE that bypasses `mark_dirty_on_update`, asserted statically against the statement text AND the module's call graph because the shared fake writes only what a SET clause names. Two settings readers on the Zoho read client with `ZohoScopeError`/`ZohoApiVersionError` so **no-scope, no-data and no-such-endpoint are three different reported outcomes** — and no-scope is the *expected* first answer, since the tenant's refresh token was never minted with `ZohoCRM.settings.*`. D-CRM-10's clamp landed in `admin.py::_validate_status` reading the ROW rather than the payload (a PATCH naming only `type`, or only `probability`, contradicts the rule only in combination with what is stored). `?tab=settings` is the headless-API fix and needs no Zoho token at all. **Nothing has been run against the tenant — dry run included.** · **g** forecast & funnel reports off `crm_status_changes` — **BUILT 2026-08-07** (branch `ws-26g-reports`, **no migration**: 144's `crm_status_changes` already carried every column). `routes/crm/reports.py` = four read-only endpoints (`/crm/reports/{pipeline,funnel,win-loss,owners}`) on the shared gated router, plus `?tab=reports`. **`WEIGHTED_SQL` lifted into `core.py`** (pipeline re-exports it) so the forecast formula has ONE definition, and `core.status_wire` absorbed two duplicate status projections instead of gaining a third. **The cross-language parity mechanism is minted here, not inherited** — the `priority.ts ⟷ priority.py` "precedent" is two hand-kept mirrors joined by a comment with no shared fixture anywhere: `tests/fixtures/crm_weighted_parity.json` (new directory) is read by BOTH pytest (through the emitted SQL, whose expression `_crm_fakes._WEIGHTED_SUM_RE` parses out of the statement text) and vitest (through `board.ts::weightedDeal`/`weightedRows`). ⚠️ **The funnel is defined against what the log RECORDS, not what its name suggests**: `crm_status_changes` logs transitions only, so all 551 imported deals have zero rows and "entered" is a VISITED-SET union (`from_status`, `to_status`, and the deal's current stage) — a `to_status` count reports an empty funnel for the whole live board; dwell is grouped by **`from_status`** (the stage being LEFT, the opposite key from the naive reading); the log stores NAMES, so a renamed lane orphans its history and orphans are REPORTED in `unmatched`, never dropped; and NULL `closed_at` — every imported closed deal until f4's owner-gated backfill runs — falls outside the trailing window, with the count reported so a 0% win rate is explicable rather than mysterious. The lost-reason breakdown carries a NAMED unattributed bucket: the earlier "complete by construction" claim is FALSE, since the importer bypasses both gates and `lost_reason_id` is `ON DELETE SET NULL`. **No `GROUP BY` is emitted** — the ticket asked for the choice to be stated: per-key aggregates in `get_pipeline`'s shape, because the weighted expression binds the lane's own default as `:stage_probability` and a grouped statement would stop BEING the expression the fixture and the fake read. 47 hermetic cases + 20 vitest + the 14-row shared fixture on both sides, 5 mutants red. **Two findings:** the `entity_type='deal'` mutant initially SURVIVED (the funnel keys through deal ids, so a lead row is excluded twice over) — closed with a row stamped `lead` against a DEAL's id, which is realistic precisely because `entity_id` has **no FK**; and `_crm_fakes`' `lower(col) = :param` reader matched a NULL column, which SQL never does, making the unassigned-owner bucket count rows its own aggregate never summed · **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views (🟡 spec-thin, audit-narrow first). **Demo path (2026-08-07, spec §9.0): full chain + all gates intact, tickets re-sequenced not thinned; f gained f4 — imported won/lost deals have no `closed_at` (importer never stamps it), backfilled from Zoho `Closing_Date` as a labeled proxy via a direct UPDATE that MUST bypass dirty-marking or ~500 no-op pushes queue for the live tenant.** | +| **WS-29** | **Multi-tenancy — isolating organizations** *(minted 2026-08-08)* | `specs/multi_tenancy.md` | 🔴 **D-MT-1 OWNER-ANSWER REQUIRED** · 🟢 ratchet in place | **Measured 2026-08-08, not recalled: 143 app tables, SIX carry `organization_id` (`app_user`, `crm_activities`, `crm_contacts`, `crm_deals`, `org_group`, `org_role`), 137 carry none — including all 17 `pm_*`.** An `organization` table has existed since migration 130 with one seeded row (`slug='default'`) and `app_user.organization_id`; tenancy was started and never carried past access control and the CRM. ⚠️ **`app_user.email` is globally UNIQUE, so today one person = one organization structurally — D-MT-1 asks whether that stays true, and everything else is downstream of the answer.** Projects is cheaper than its size suggests: 128 `FROM`/`JOIN` references to `pm_*` but **one** closure query (`_VISIBLE_PROJECTS_SQL`), so the retrofit is a column on 17 tables, a predicate in one query, and one line in the `Visibility` resolver. 🔴 **Blocks WS-27's production ClickUp import** (§6 gate (a)): importing a real workspace into 17 unscoped tables turns a one-line default on empty tables into a backfill on live rows. `tests/unit/test_tenancy_boundary.py` freezes the 137 and fails any NEW unscoped table — a ratchet, not a demand for the retrofit. Sequence in spec §5: **WS-29a** (`pm_*` key, urgent, gates the import) → **b** (tenant predicate) → **c** (RLS behind a flag) → **d** (the remaining 120 by family). | | **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e + i BUILT 2026-08-06 · j + k + l + m + n + o + p + s BUILT 2026-08-07 · q + r + t BUILT 2026-08-08 — the ClickUp parity backlog (§11.2) is now CLOSED** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced · ✅ **t BUILT 2026-08-08** (D-PM-11 + D-PM-12 answered, gate (e) cleared) | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g — and n is now BUILT (2026-08-07), so that dependency is SATISFIED**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end. g itself stays 🔴 OWNER-GATE for its own reasons (§6); this changes the prerequisite, not the gate · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store).. **j BUILT 2026-08-07** (mig `152_projects_notifications.sql`, `routes/projects/notifications.py`, the header bell + mention picker; 39 hermetic + 27 vitest cases, 10 mutants red): closes §11.2's second gap — *"assignment is silent"*. **Three rules decide who hears**, each the whole reason for a rule: never the actor (a bell that pings you about your own click gets muted, and a muted bell notifies nobody about anything); never an agent (they are handed work by the WS-27f dispatch sink, so a row addressed to one sits unread forever inflating a badge nobody can clear — enforced in Python AND by a CHECK); and **never somebody who cannot open it**, which is the security property: the notification carries the task's TITLE, so delivering one outside the grant closure leaks it and lands them on a 404. That third rule needed `resolve_visibility_for`, which answers for a THIRD PARTY — `resolve_visibility` reads a `UserContext` and the recipient of a mention has no request in flight — by reading the tables `/auth/me` reads and handing them to the **real** `build_access`, so wildcards and allow/deny overrides resolve identically on both paths. Notifications are written **inside the transaction**, not emitted on the bus: `emit` swallows failures by construction so a broken workflow can never fail a task edit, which is right for agent dispatch and wrong here. A mention is an **address, not a name**, because 148 dropped `UNIQUE(name)` — `@Priya` has no answer. **Two bugs found on the way in, both shipping at the time:** every project-task file upload was answering **422** (`ACTIVITY_TYPES` never learned 150's `attachment`; all 25 attachment tests passed because they monkeypatch `record_activity` — the seam under test was mocked out), fixed with two tests that READ the migrations; and `/projects?task=` did nothing, though the People Center has linked there since WS-28b. **WS-27b's UI BUILT 2026-08-07** (`components/ImportClickUp.tsx`, `lib/importPlan.ts`; 18 vitest cases, 3 mutants red): the importer shipped with WS-27b and was **unreachable from the product** — the empty state named "import a ClickUp workspace" and no control anywhere did it, so a new install stayed empty and the only route to real data was curl. Three steps, only the last of which writes: Preview (`/plan`, reads the tenant) → Dry run (`dry_run:true`, exercises the flattening) → Import. **The mapping stays the owner's act (D-PM-10)** and the UI is built so it stays one: the suggestion is pre-filled and shown beside its confidence IN WORDS ("a guess — check it", not `0.45`, because a bare number invites acceptance without looking), and a CONFIRMED mapping always beats a fresh suggestion so a re-run never silently re-maps a Space somebody already ruled on. Unmapped Spaces are a notice rather than a blocker, matching the importer. ⚠️ The gate is unchanged and is now exactly one click: building this was agent-safe, pressing Import is the owner's act, and no agent has run either endpoint against production. **The Tasks-app mirror path BUILT 2026-08-07** (`routes/projects/import_tasks.py`, `POST /import/from-tasks`; 43 hermetic cases, 7 mutants red) — owner-directed: *"just show up all the data that is there in the Tasks app inside the Projects app"*. A SECOND importer rather than a flag: the ClickUp one needs a live token, spends LLM budget and demands a Center mapping BEFORE anything is written, which is backwards for "show me my work today". This reads `gtd_projects`/`gtd_items` — the mirror already on the box — so no API call, no token, no model spend, and it works when the connector is stale. One named department, with the real ClickUp shape beneath it — **Space → Folder → List** rebuilt as projects, each carrying its own `clickup_id`/`clickup_kind`, so promoting a Space node is how the department split happens later. **Verified against a real Postgres** (full migration set + seeded mirror), which found THREE defects the hermetic suite could not: `gtd_projects.space_id` is LOCAL-only so the Space was always NULL; `pm_projects` has no `clickup_snapshot` column, so the first real click would have 500'd (this shipped in #393 and was fixed before anybody pressed it); and the preview under-counted 4 vs 7 because container nodes were only tallied on the write path. The root IS org-granted (same act as `create_node`, narrower than bulk-granting a tenant). Pinned: nothing outside `pm_*` is written; only `source <> 'LOCAL'` rows are read (a personal capture must not be published to a shared board); the provider's own status names are kept; an orphaned task is COUNTED, not dropped. **Mutation found a real gap**: deleting `dry_run` from the write guards left every test green, because on a FIRST dry run `root_id is None` blocks the write anyway — on a SECOND one the department exists and `dry_run` is the only protection. That is the realistic case (preview → import → preview again) and it now has its own test. **k BUILT 2026-08-07** (`routes/projects/filters.py`, `lib/grouping.ts`, `components/FilterBar.tsx`; 34 hermetic + 24 vitest cases, 13 mutants red, and 23 checks against a REAL Postgres) — closes §11.2's third gap, the one whose name was the sentence *"my open bugs in Ops, grouped by assignee"*. **ONE filter builder serves both the list endpoint and saved views**, because a saved view is nothing but a stored set of these filters and two implementations would drift — a *saved* view showing a different set than the same filters typed by hand is the one thing it may not do, so a test compares the two outputs directly. **Every filter is a WHERE clause**: paging happens in SQL, so a filter applied in Python after `LIMIT` returns short pages, and *"page 2 is empty but there are 40 more"* is a bug people work around for months instead of reporting. **`overdue` means past due AND still open** — a finished task with a past due date is done, and permanent red is how a board teaches people to ignore red. **An unknown category is a 422 naming the five real ones**, not an empty board a client reads as "this project is empty". Unknown config **keys** are DROPPED while a bad **value** falls back, because those are different failures: a view is a preference written by an older client, so refusing one over an unrecognised key would make every deploy a migration of everybody's saved views, whereas rendering still has to produce something. On the board, **a task with two assignees appears in BOTH columns** (it is both people's work; picking one hides it from the other, so the header counts tasks not group sizes), **empty status lanes are kept** while every other grouping drops empties (a missing "In progress" column reads as "no such state", not "nothing in progress"), and **dragging is offered only when the columns are statuses** — a drop writes the field the columns represent, and status is the one that is a plain PATCH, so a card that can be dragged into a column which cannot accept it and snaps back is worse than an honestly static column. `toConfig` is deliberately NOT `toQuery`: a query string carries only text so `toQuery` writes `"true"`, and `fromConfig` refuses a string where a toggle belongs, so a view built from query shape would come back with every toggle silently cleared. The project's **order-bearing board is withheld from the chips** — `tree.py` seeds one `board` view per project and it owns every `pm_view_task_positions` row, so offering its ✕ would offer to delete every hand-arranged position; saved views sit at position 300 above the seeded pair and `orderBearingView` is one function used by both the drag handler and the delete guard. **A FIFTH live bug, found the same way as the previous four:** `due_before` was `CAST(:due_before AS timestamptz)` with the raw query-string value — asyncpg infers the parameter's type FROM that cast and then refuses to encode a `str`, so the query never reached Postgres and **`?due_before=…` answered 500** while the hermetic fake, which agrees with whatever SQL it is handed, stayed green. `parse_when` parses on this side and binds a real `datetime`; garbage is a 422 that says what was expected; a naive value is read as UTC rather than inheriting the connection's TimeZone. Two tests — one on the bound value's TYPE, one refusing any `CAST(:param AS timestamp…)` anywhere in the builder — so the next `after=` filter written the obvious way fails in CI instead of in front of a member. **l BUILT 2026-08-07** (mig `155_projects_custom_fields.sql`, `routes/projects/custom_fields.py`, `lib/customFields.ts` + the panel block and the Fields dialog; 47 hermetic + 36 vitest cases, 23 mutants red, 35 checks against a REAL Postgres) — ClickUp's signature feature, and the shape §5's non-goals already recorded as the additive path: **definitions in a table, values denormalised onto the task as JSONB keyed by `field_key`**. NOT a row per (task, field): that is the textbook EAV answer and costs a join per field on every board paint — five fields across two hundred imported tasks is a thousand rows to gather and re-pivot, per render — whereas the JSONB column arrives with the task for free. **The cost is stated rather than discovered**: a value is not referentially tied to its definition, so the DATABASE cannot stop a key no definition owns from being written, and that guarantee moves into Python. Hence the validation IS the feature: an unknown key is a **422 not a silent drop** (a typo that no-ops looks exactly like a save); a patch **MERGES** (a client that knows three of five fields must not wipe the other two — and an older client, or an automation written before a field existed, is precisely that client); an explicit **null CLEARS the key** rather than storing a null, because it is the only way to express "unset this" and a stored null makes "never filled in" and "deliberately emptied" one value in every filter; and **`true` is not the number 1** — `isinstance(True, int)` is True in Python, so the coercers are one-per-type in a dispatch dict specifically so the boolean check can never drift below the number one. **The deliberate departure from Paca:** its research notes record "deleting a definition does not clean task data" as an accepted cost; it is NOT accepted here, because a key left in the JSONB is invisible — no definition means no column, no form row, no filter — until somebody recreates the name and every old value resurfaces carrying the new meaning. The cleared count is reported (R7/R8). Two things a definition may not change once values exist, both because the stored values would stop meaning what they say: **`field_key` is never editable** (it is the identity every value is filed under) and **`field_type` is a 409 naming the count** (text→select cannot re-interpret what is already written); dropping a select option some task holds is refused the same way, adding one is free, and the UI shows the derived key while the name is still being typed since that is the last moment anybody can change it. **Custom fields are REVERTIBLE, which is what makes them first-class:** `patch_task` folds a custom edit into the SAME `field_change` activity under `custom.` rather than inventing an activity type — `record_activity` refuses a type the CHECK does not list, the trap that made every attachment upload answer 422 — and revert restores by **merging onto what the task holds NOW**, never writing back the whole object, since another field may have been edited since and replacing the blob would silently undo that too. **A bug the ticket's own tests caught before it shipped:** `changedValues` compared a form's boolean against a `null` baseline, so a task with an unanswered checkbox sent `open:false` on EVERY save and posted a timeline entry for an edit nobody made — a checkbox has no "unset" state to render, so `false` is its baseline. **And a fence that was quietly a subset check:** `test_projects_routes` asserted a LIST of mounted paths, which catches the module somebody remembered to add a path for; it now also reads the package directory and asserts every module declaring a `@router` route is imported by `__init__.py` — the C1 trap where a missing import mounts nothing while every direct-call test still passes. Verified by deleting the import and watching it fail. **m BUILT 2026-08-07** (mig `156_projects_tags.sql`, `routes/projects/tags.py`, `lib/tags.ts` + the panel picker, the filter row, a `tag` board axis and the Tags dialog; 31 hermetic + 37 vitest cases, 16 mutants red, 30 checks against a REAL Postgres) — the row the research notes left open ON PURPOSE: `paca_pm_research_2026-08.md` row 13 REFUSED Paca's model (*"a bare jsonb string array on tasks. No registry, no colors, no rename/merge — the weakest part of Paca's model"*) and §5 shipped `pm_tasks.tags TEXT[]` in its place with a registry named as additive later. **The array STAYS** — a join table would add a row per tag per task and a join to every board paint, to buy referential integrity this app enforces in one place, whereas the array arrives with the task and its GIN index (146) already answers "tagged X". What the registry buys: **one spelling per tag** (identity is case-INSENSITIVE via a unique index over `lower(name)` so two racing requests cannot create both, display is case-PRESERVING, and the task's array stores the REGISTRY's form — which is what makes "filter by bug finds all of it" true rather than aspirational, and what lets a rename be one statement); **rename**; **merge**; and a **colour**. **Applying an unregistered tag REGISTERS it** — refusing would make tagging a two-step errand (leave the task, create the tag, come back), which is how tagging gets abandoned, and an abandoned tag set is worse than a messy one. **The cost is stated: every typo becomes a tag** — which is exactly why merge is here and is not optional, and why the picker SHOWS the moment of creation rather than minting silently. **A rename onto an existing name is a 409, not a silent merge**: different operations, different outcomes, and one of them destroys a tag — quietly doing the destructive one because the names collided is what stops people using a rename button. **A task carrying BOTH tags ends a merge with the target ONCE** — the case that is easy to get wrong, and getting it wrong leaves a duplicate that renders twice and survives the next merge too; `merged_tags` is pure so that case is asserted directly, and the rewrite runs over affected rows in Python rather than as an `array_replace`, which would leave the duplicate. **TWO tag filters** because both questions get asked and neither answers the other: `tags` is ANY (`&&`) and `tags_all` is ALL (`@>`); collapsing them would silently pick a meaning, and with three tags the answers differ by almost everything. On the board a task with three tags appears in three columns, the same honesty as two assignees appearing in both theirs. **The migration backfills AND rewrites data, deliberately and narrowly:** `tags` has been on `pm_tasks` since 146 and the import path writes them, so an empty registry beside a tagged corpus means the first rename finds nothing; the winning display form is **the spelling people actually use** (most frequent, ties broken deterministically — `min()` alone would canonicalise 400 "Bug" to a single stray "BUG"), and task arrays are then made to agree, only ever swapping one CASING of a tag for another, with the count reported in a NOTICE. **A bug the live run caught in that block:** the canonicalisation used the implicit-comma `FROM pm_tasks t, unnest(t.tags) ... LEFT JOIN` form, where the LEFT JOIN binds only to `unnest(...)` and `t` is not in scope for its ON clause — the migration aborted with "invalid reference to FROM-clause entry for table t". Rewritten as `CROSS JOIN LATERAL … WITH ORDINALITY`, which also fixed a second problem the first version would have shipped: `array_agg(DISTINCT ...)` sorts by its own expression, so every task's tag list would have come back alphabetised. **n BUILT 2026-08-07** (`routes/projects/bulk.py` → `POST /projects/tasks/bulk`, `lib/selection.ts`, `components/BulkBar.tsx` + checkboxes on board and list; 35 hermetic + 32 vitest cases, 16 mutants red, 34 checks against a REAL Postgres; **no migration**) — **the ticket §11.3 names as gating g, so that prerequisite is now satisfied.** It **reuses `automation.apply_task_patch` rather than growing a second writer**: that service exists because WS-27f needed an edit indistinguishable in validation from a human PATCH, and a bulk endpoint with its own field handling would be a third opinion about what a task edit is. Tags go through the same registry the panel uses, because the registry cannot be true if bulk is a second door into the array. **Status is named, never keyed — load-bearing here rather than stylistic**: a selection spans projects and a status id belongs to one root, so `status_id` for fifty tasks across three projects puts two thirds of them in a lane that is not theirs; it is a 422 with its OWN message, because it is the mistake somebody makes by copying a single-task PATCH body and "unknown field" would not explain why the thing that works on one task is refused on fifty. **Assignees and tags are ADD/REMOVE, never SET** — "assign these to Priya" means ALSO Priya, and a replace wipes every individual assignment the fifty tasks already carried; the destructive spelling is absent rather than discouraged. **Shape validated once, outcomes per task**, because those are different failures: an unsettable field is the same mistake for all fifty (422 before any write), while a status name present in one project and absent from another is a fact about THAT task — failing the batch for it makes a mixed selection unusable, which is precisely the selection somebody makes after an import. **An invisible task is SKIPPED, not an error** (R5: per-id reporting says exactly what a per-id 404 says, and aborting would let a caller probe for existence). **One transaction** — a re-triage that half happened is harder to recover from than one that did not, because nobody can tell which half. **Re-asserting a value is not a change** (`moved_people` is pure so the claim is asserted directly): fifty tasks already Priya's would each gain a timeline entry saying nothing, and a count nobody can trust is worse than no count. **ONE notification per person per batch, not one per task** — being handed fifty tasks should ring once and say fifty; fifty bells is a bell people turn off, which is WS-27j's own argument applied to the case that would have broken it. In the browser the selection is **pruned whenever the filter changes** (select forty, narrow to three, press Done must not act on thirty-seven nobody can see), a shift-click ranges over the board's ON-SCREEN order, a two-assignee card drawn in two columns counts once, and the outcome line names every category including the boring ones. **AND IT UNCOVERED A SHIPPED WS-27j BUG** (spec §11.12): `notifications.deliverable` probed only `project_clause('t.root_project_id')` while `core.task_visibility_clause` — whose docstring warns a second implementation "would drift the moment one is edited alone" — carries TWO ways in. So (1) anybody assigned work in a project they hold no grant on was judged undeliverable: they could OPEN the task, the assignment notified nobody, and the response told the assigner they could not see it — the silent assignment WS-27j exists to end, still open for the most common case in a grant-scoped app; and (2) scoping to `root_project_id` ALSO missed a grant made on a SUBPROJECT — the old test asserted that scoping on the argument that "probing `project_id` would miss a grant made on an ancestor", which is BACKWARDS, as a real-Postgres run showed: the closure is recursive and expands DOWNWARD. Fixed to use the shared clause; the test that encoded the wrong reasoning now records why it was wrong. **A fake collision the fix exposed:** `FakeDB` matched the rule-3 probe by substring, and the new clause embeds both `pm_task_assignees` and the closure's `UNION` — exactly the AUDIENCE branch's fingerprint — so `deliverable` got a list of assignees where it expected a visibility answer and four tests failed for a reason unrelated to the code under test. A fake that dispatches on substrings needs fingerprints that are SPECIFIC, not merely present. **o BUILT 2026-08-07** (mig `157_projects_recurrence.sql`, `routes/projects/recurrence.py`, `lib/recurrence.ts` + the repeat row in the task panel; 45 hermetic + 27 vitest cases, 31 mutants red, 39 checks against a REAL Postgres) — **NO SCHEDULER, and that is FORCED rather than chosen**: §5's non-goals say `/workflows` is the only engine (ADR-028/D6), so a recurrence worker here would be exactly the second engine the spec forbids. The successor is created **when a task CLOSES** — `apply_status_transition` already owns that moment, so a task finished from the board, from My work, from an automation or from a bulk edit all recur identically, and a second call site would be a fifth way to finish a task that forgets to. **The cost is stated:** a series only advances when somebody finishes the current one — a monthly report nobody closes does not pile up twelve copies (right), but a daily standup nobody ticks does not appear tomorrow (the honest limitation); materialising ahead is already reachable through the engine that owns scheduling (cron trigger + the `pm_task` node WS-27f added), so nothing needs undoing. **The anchor is PER RULE because the two answers mean different things**: `due` keeps the schedule ("stock count on the 1st" stays on the 1st however late the last was closed, so the series does not drift) and `completed` measures from when the work was actually done ("water the plants every 3 days" restarts when you water them). A `due` anchor also **catches up** — a monthly task closed six weeks late would otherwise produce a successor already overdue the moment it appeared — and the missed occurrences are SKIPPED rather than backfilled, because nobody wants four copies of a standup they did not attend. **The date arithmetic is where this is either right or quietly wrong for a year**, so it is pure and each case is one assertion: January 31st monthly (clamped at COMPUTATION time and stored as asked — storing the clamp permanently demotes the rule to the 28th after its first February), February 29th yearly, "every other Mon and Thu" (within a week it takes the next allowed day and only jumps `interval` weeks when the week runs out; a naive `+14 days` alternates between the two days instead of giving both days of every second week), and a 09:00 standup staying at 09:00. **Closing twice must not spawn twice** — a task can cross into `done` repeatedly (close, reopen to add a note, close again) and every crossing hits the same seam, so `recurrence_spawned_at` guards it and is NEVER cleared: reopening undoes `completed_at` but does not un-emit a successor that may already have been worked on. **Stopping a series keeps the work** (detach, not delete): they are real tasks, some finished, and a button that swept away three months of completed reports is one nobody presses twice. **TWO BUGS THE LIVE RUN CAUGHT AND READING COULD NOT:** (1) the weekly CHECK passed the very row it existed to reject — `array_length('{}', 1)` returns **NULL**, `NULL >= 1` is NULL, and a CHECK only FAILS on false, so a weekly rule with no weekdays inserted happily past a constraint that looked correct; `coalesce(…, 0)` fixes it and a test asserts the coalesce is present, since the hermetic suite has no database to try the expression on; and (2) `_next_number`/`_default_status` were reimplementations, one of which invented a column (`last_number`; the real one is `last_value`) — replaced by `core`'s own `next_task_number` and `load_default_status`, which is the same mistake WS-27n had just been careful to avoid, made two tickets later in the same package. **A third, caught by its own test:** `int(rule.get("interval") or 1)` turns an explicit `0` into "every 1" — a typo that looks exactly like a save, and one the DB CHECK would then have refused as a 500 rather than a 422. In the browser **the SENTENCE is the feature** — a form of five controls is a shape, whereas "Every 2 weeks on Mon, Thu, keeping to the schedule" is something somebody can check before committing, shown LIVE rather than on save because picking the wrong anchor is invisible until a cadence has drifted for three months; the occurrence limit reads as what is LEFT not the cap, and switching frequency clears the fields the new one does not use so a stale `day_of_month` cannot reappear. **p BUILT 2026-08-07** (`routes/projects/relations.py` → `GET /tasks/{id}/relations`, `lib/relations.ts` + the relations block in the panel; 21 hermetic + 16 vitest cases, 11 mutants red, 19 checks against a REAL Postgres; **no migration**) — closes *"data with no surface is a promise the product does not keep"*. **BOTH halves were genuinely unreachable, for different reasons:** links could be CREATED and DELETED since WS-27a but never LISTED (`get_task` returns a *count*), and subtasks could be created from the panel but never listed either (`?parent_task_id=` existed and nothing called it). What was missing was a way to read them and **one rule nobody had written down: `blocks` may not form a cycle.** `assert_no_task_cycle` has guarded `parent_task_id` since WS-27a and the identical hazard sat unguarded on links — A blocks B blocks C blocks A is a deadlock no human can resolve by finishing something, and every walk over it runs forever. The new guard is bounded by the same `MAX_DEPTH` and **tracks what it has seen**, because data can ALREADY contain a loop (every link created before the guard went in unchecked) and the walk must terminate over one rather than spin. **Only `blocks` is guarded** — a cycle in `relates_to` is redundant, not harmful, and refusing one would be a rule with no failure to prevent. **Blocked-ness is DERIVED and SHOWN, never ENFORCED**: refusing to close a blocked task is the obvious next step and is deliberately not taken, because dependencies in a real workspace are approximate and a tool that will not let somebody finish work they have finished is one they route around — after which the links stop being maintained and the feature is worse than absent. **Visibility is applied to the CHILDREN, not inherited from the parent**: a subtask can be moved into a project the reader cannot see, and listing it because its parent is readable would disclose a title from behind a grant (the live run asserts both the absence and that the title does not appear). ONE endpoint carries BOTH directions, because `blocks` outgoing means "this holds those up" and incoming means "this is waiting" — a client given one side would ask twice and still not know which was which; **Blocked by is shown FIRST** since it is the only section that changes what to do next, and empty sections are dropped because six empty headings on every task is how a panel becomes something people scroll past. Progress counts the status CATEGORY not `completed_at` (a project can name its finished lane anything, and `cancelled` is resolved), and reads as "1 of 3" rather than 33% | | **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | ✅ **a + b BUILT 2026-08-06 · b-write BUILT 2026-08-07** · 🟢 c–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE).. **a BUILT 2026-08-06** (mig `148_people_key_shape.sql` + `scripts/import_hr_people.py`; 22 cases, 11 mutants red, 1 equivalent): `UNIQUE(name)` dropped, partial unique on `lower(email)`, status CHECK. **P-1 did not name its own consequence** — the HR importer upserts `ON CONFLICT (name)` and would have failed outright, so a `source_key` (`:`) carries the upsert instead, backfilled BEFORE the constraint is dropped while `name` is still distinct. **Neither new constraint may block a deploy** (main was bitten twice this month): a duplicate address is quarantined into a new `email_conflict` column with a deterministic winner rather than failing `CREATE UNIQUE INDEX`, and the status CHECK is added `NOT VALID` then validated in a guarded block, so an unanticipated legacy value leaves a NOTICE instead of stopping the deploy. ⚠️ `schema.generated.sql` NOT refreshed — needs a live DB; regenerate on the first deploy that applies 148. **b BUILT 2026-08-06** (mig `149_people.sql`, `routes/people/`, `src/app/people/`; 32 hermetic + 28 vitest cases, 11 mutants red): `/people` directory, person page with all four panels, and the People Center's "Directory & org chart" sub-app flipped live — closing WS-13's outstanding read view. **Its own feature slug**, not `feature:tasks`: a manager who needs the org chart should not be handed the personal GTD task manager to get it. The gate is new but the HR **projection is imported** from `tasks.core` and a test asserts the function's *identity*, since two answers to "may this caller see skills" are two answers waiting to drift. Three filters (the `q` skills clause, `skill`, `has_capacity`) are dropped without `admin:members:read` so search cannot become an oracle for the hidden field — and the response carries `hr_visible` so the UI says "restricted" rather than leaving a blank strip to read as "nobody filled it in". Load is **computed from open assigned tasks** and carries `unestimated`, because a bar built from the estimate sum alone shows somebody holding thirty un-estimated tasks as completely free. The work panel is scoped by the **viewer's** grants and answers `available:false` without `feature:projects`. **Registration is FIVE places, not four** — the fifth is `test_org_access_enforcement.GATED_ROUTERS`, hand-maintained, where an absent router is unchecked rather than passing; also added the named `test_projects_is_registered_on_both_sides` that WS-27a never wrote. **b-write BUILT 2026-08-07** (`people/components/PersonEditor.tsx`, `people/lib/form.ts`, `people/lib/write.ts`; 26 hermetic + 23 vitest cases, 7 mutants red): closes the regression the 2026-08-06 scope narrowing opened — deleting the tasks app's People view took `PersonEditor` with it, and with it the only UI for creating a person, editing skills and uploading a résumé. The GET-only `/api/people` proxy is **unchanged**: the writes go to `/api/tasks/people`, where they have always lived. Controls are absent rather than disabled, driven by a new **`can_manage`** flag on the reads — hide-rather-than-disable is impossible unless the read tells the UI, and the alternative is drawing the button and letting the click find a 403. **Restoring it turned up three ways migration 148 had already broken the write routes**, each a 500 in front of an admin rather than a test failure: the status vocabulary moved (49's `inactive`/`on_leave` vs 148's CHECK) and is now ONE tuple shared by filter, facets, select and validation; `create_person` still refused a duplicate NAME, preserving precisely the behaviour 148 dropped `UNIQUE(name)` to remove; and nothing checked the address 148 made unique, so a duplicate was an `IntegrityError` instead of a 409 naming the other row. **The lesson recorded in spec §10:** a migration that changes a table's shape has to be walked against every route that WRITES it — the read routes were built after 148 and were correct by construction, the write routes predated it and were never revisited | @@ -654,6 +655,13 @@ should the probe report no-scope, is likewise the owner's act · **the five WS-27 Projects gates** (`specs/project_management_app.md`), (a)–(d) registered 2026-08-05, (e) added 2026-08-08: **(a) running either ClickUp import endpoint against the production workspace** — +⚠️ **ALSO BLOCKED ON WS-29a AS OF 2026-08-08, and this is now the binding +constraint rather than the mapping decision.** CommandCenter is becoming +multi-tenant and all seventeen `pm_*` tables carry no `organization_id` +(`specs/multi_tenancy.md` §2). Importing a real workspace now writes hundreds +of tasks, activities, attachments and grants into unscoped tables, which turns +a one-line default on empty tables into a backfill plus an `ALTER` on live +rows. The import is not wrong, it is **early**: land WS-29a first. — building both is AGENT-SAFE; executing them is not. `POST /projects/import/clickup/plan` writes nothing to our DB but **reads the live ClickUp tenant** and spends LLM budget classifying it; `POST /projects/import/clickup` writes the live DB, and diff --git a/tests/unit/test_tenancy_boundary.py b/tests/unit/test_tenancy_boundary.py new file mode 100644 index 00000000..1909e0a3 --- /dev/null +++ b/tests/unit/test_tenancy_boundary.py @@ -0,0 +1,257 @@ +"""The tenant boundary, as a ratchet (WS-29). + +⚠️ **CommandCenter is becoming multi-tenant, and today 137 of its 143 tables +carry no tenant key.** That is not a bug list — it is the honest state of a +system built for one organisation. The bug would be adding the 144th. + +Tenancy was started and not carried through: `organization` exists with a +single seeded row (`slug='default'`), `app_user` gained `organization_id` in +migration 130, the CRM scoped three of its tables, and `org_group`/`org_role` +scoped themselves. Everything since — Projects, GTD, Email, WhatsApp, +Workflows, Apps, Chat — did not. + +This test does not demand the retrofit. It demands that the number stop +growing, on the model of the frontend's `conformance.test.ts`: + + * a table **not** in the baseline must carry `organization_id` — this is the + case that matters, because it is every table nobody has written yet; + * a baselined table may stay as it is; + * a baselined table that **gained** a tenant key fails until it is removed + from the baseline, so the debt figure below is always the real one. + +That last rule is what makes the other two credible. A baseline only ever +edited downward when somebody happens to notice is a baseline that quietly +becomes fiction. + +**Scope note.** `organization_id` on the table is the *shape*, not the +enforcement. Which of RLS, an application clause, or a schema per tenant does +the enforcing is D-MT-2 in `specs/multi_tenancy.md` and is deliberately not +decided here — every one of them wants the column. +""" + +from __future__ import annotations + +import glob +import os +import re + +#: `LiteLLM_*` is a vendored product with its own tenancy model; it is not ours +#: to scope and its tables never reach our code. +FOREIGN_PREFIX = "LiteLLM" + +#: Tables that carry a tenant key today. Not a baseline — the goal state. +EXPECTED_SCOPED = { + "app_user", + "crm_activities", + "crm_contacts", + "crm_deals", + "org_group", + "org_role", +} + +#: ⚠️ FROZEN 2026-08-08 at 137. Every table predating the multi-tenant decision. +#: Adding a name here is allowed and must come with a reason in the PR; adding +#: one *silently* is how a 137 becomes a 160 without anybody choosing it. +BASELINE_UNSCOPED = { +# access_* + "access_request", +# action_* + "action_item", +# agent_* + "agent_avatars", "agent_blob", "agent_file_history", "agent_run", + "agent_skill_setting", +# app_* + "app_audit", "app_data", "app_files", "app_grants", "app_pins", + "app_tool_grants", "app_versions", +# apps_* + "apps", +# audit_* + "audit_event", +# chat_* + "chat_message", "chat_session", "chat_session_agent", + "chat_session_participant", +# copilot_* + "copilot_config", "copilot_event", +# crm_* + "crm_deal_contacts", "crm_deal_statuses", "crm_lead_statuses", + "crm_leads", "crm_lost_reasons", "crm_organizations", + "crm_status_changes", "crm_sync_cursors", "crm_zoho_tombstones", +# custom_* + "custom_api_definitions", +# customer_* + "customer", +# deal_* + "deal", +# dynamic_* + "dynamic_agents", +# email_* + "email_accounts", "email_actions", "email_ai_drafts", + "email_assistant_settings", "email_attachments", "email_cold_senders", + "email_contacts", "email_embeddings", "email_executed_rules", + "email_folders", "email_knowledge", "email_learned_patterns", + "email_messages", "email_newsletters", "email_rule_guidance", + "email_rule_patterns", "email_rules", "email_senders", "email_sync_log", + "email_thread_status", "email_voice_profiles", +# feature_* + "feature_catalog", +# gtd_* + "gtd_attachments", "gtd_contexts", "gtd_day_state", "gtd_folders", + "gtd_horizons", "gtd_items", "gtd_people", "gtd_person_resumes", + "gtd_projects", "gtd_reviews", "gtd_rollover_log", "gtd_settings", + "gtd_spaces", "gtd_waiting", +# live_* + "live_session", +# mcp_* + "mcp_servers", +# meeting_* + "meeting", "meeting_bot", "meeting_note", "meeting_recording", +# message_* + "message", +# model_* + "model_config", +# notes_* + "notes_glossary", +# org_* + "org_group_member", "org_role_permission", "org_settings", +# organization_* + "organization", +# pending_* + "pending_actions", "pending_commit", +# person_* + "person", +# plugins_* + "plugins", +# pm_* + "pm_activities", "pm_custom_fields", "pm_notifications", + "pm_project_grants", "pm_projects", "pm_recurrences", "pm_tags", + "pm_task_assignees", "pm_task_attachments", "pm_task_counters", + "pm_task_links", "pm_task_personal", "pm_task_statuses", "pm_task_types", + "pm_tasks", "pm_view_task_positions", "pm_views", +# project_* + "project", +# provider_* + "provider_keys", +# schema_* + "schema_migrations", +# summary_* + "summary_run", +# task_* + "task", "task_accounts", +# transcript_* + "transcript_segment", +# user_* + "user_permission_override", "user_role", +# wa_* + "wa_accounts", "wa_ai_drafts", "wa_categories", "wa_chat_avatars", + "wa_chat_labels", "wa_chat_status", "wa_chats", "wa_commitments", + "wa_contacts", "wa_group_summaries", "wa_labels", "wa_media", + "wa_message_embeddings", "wa_messages", "wa_saved_replies", + "wa_sync_log", "wa_templates", +# workflow_* + "workflow_modules", "workflow_run_pauses", "workflow_runs", + "workflow_triggers", "workflow_versions", +# workflows_* + "workflows",} + + +def _scan() -> tuple[set[str], set[str]]: + """Every table the migrations define, and which of them are tenant-scoped. + + Read from the migrations rather than from `schema.generated.sql`, which is + stale (it predates migration 146 and knows about none of the `pm_*` + tables), and rather than from a live connection, which this suite does not + have. + + **`ALTER TABLE … ADD COLUMN organization_id` counts.** That is how + `app_user` got its tenant key in migration 130, so a `CREATE TABLE`-only + scan reports the one table that matters most as unscoped — it did, in the + first version of this file, and the answer was checked against a real + Postgres before this was written. + """ + tables: set[str] = set() + scoped: set[str] = set() + for path in sorted(glob.glob("infra/postgres/*.sql")): + if os.path.basename(path) == "schema.generated.sql": + continue + with open(path, encoding="utf-8") as handle: + src = handle.read() + for match in re.finditer( + r"CREATE TABLE (?:IF NOT EXISTS )?([a-z_][a-z0-9_]*)\s*\((.*?)\n\);", + src, + re.S, + ): + tables.add(match.group(1)) + if re.search(r"\borganization_id\b", match.group(2)): + scoped.add(match.group(1)) + for match in re.finditer(r"ALTER TABLE\s+([a-z_][a-z0-9_]*)(.*?);", src, re.S): + if re.search(r"ADD COLUMN[^;]*\borganization_id\b", match.group(2)): + scoped.add(match.group(1)) + return tables, scoped + + +def test_the_scan_finds_the_migrations_at_all() -> None: + """The failure that would make every other assertion here vacuous: a glob + matching nothing gives an empty set, which satisfies every `not new` below.""" + tables, scoped = _scan() + assert len(tables) > 100, f"only found {len(tables)} tables — the glob is wrong" + assert scoped <= tables + + +def test_the_baseline_names_no_table_that_no_longer_exists() -> None: + """A baseline naming a dropped table overstates the debt, and the count in + the docstring stops meaning anything. + + Written after the first version of this file asserted that every table is + in one of the two literal sets — which failed a NEW, correctly-scoped + table for not being listed as expected-scoped. That is friction with no + benefit: `EXPECTED_SCOPED` pins that the six known ones are real, it is not + a register every future table must join. + """ + tables, _ = _scan() + stale = sorted(BASELINE_UNSCOPED - tables) + assert not stale, f"BASELINE_UNSCOPED names tables that do not exist: {stale}" + + +def test_a_new_table_must_carry_a_tenant_key() -> None: + """⚠️ THE rule. Everything else here is bookkeeping. + + A table added from now on is a table added while the system is knowingly + becoming multi-tenant, and backfilling a tenant key onto live rows costs + orders of magnitude more than declaring one on an empty table. + """ + tables, scoped = _scan() + unscoped = tables - scoped - {t for t in tables if t.startswith(FOREIGN_PREFIX)} + new = sorted(unscoped - BASELINE_UNSCOPED) + assert not new, ( + f"{new} has no `organization_id`. CommandCenter is becoming " + f"multi-tenant (specs/multi_tenancy.md): give it one, or add it to " + f"BASELINE_UNSCOPED with the reason in your PR." + ) + + +def test_a_table_that_gained_a_tenant_key_leaves_the_baseline() -> None: + """⚠️ The rule that keeps the debt figure honest. + + Without it the baseline only shrinks when somebody remembers, and the + number in this file drifts from the truth in the direction that flatters. + """ + _, scoped = _scan() + fixed = sorted(scoped & BASELINE_UNSCOPED) + assert not fixed, ( + f"{fixed} now carries `organization_id` — remove it from " + f"BASELINE_UNSCOPED and lower the count in this file's docstring." + ) + + +def test_the_expected_scoped_set_is_real_not_aspirational() -> None: + """A name in `EXPECTED_SCOPED` that is not actually scoped would make this + file claim coverage it does not have.""" + _, scoped = _scan() + missing = sorted(EXPECTED_SCOPED - scoped) + assert not missing, f"{missing} is listed as scoped but carries no key" + + +def test_the_frozen_count_matches_the_baseline() -> None: + """The docstring quotes 137. A baseline whose stated size and real size + disagree is a baseline nobody trusts.""" + assert len(BASELINE_UNSCOPED) == 137 From 80b019c4649f5957c21cbd68abca6b223386eed3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 20:35:51 +0000 Subject: [PATCH 09/22] =?UTF-8?q?docs(D-MT-1):=20one=20person,=20one=20org?= =?UTF-8?q?anization=20=E2=80=94=20owner-delegated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Put to the owner with both options costed; the answer was "go ahead with what you think is right", so the recommendation was taken as the decision. ANSWERED: (a) — app_user.email stays globally UNIQUE, and a request's tenant is derived from X-User-Email via app_user.organization_id. No app's auth seam changes shape. Taken rather than left open because it is the REVERSIBLE direction. (a)→(b) is a migration plus an org-switcher, run once, while accounts are few. (b)→(a) takes a capability away from people already relying on it. Given a delegated choice between a door that stays open and one that closes, the open one wins. The trigger to revisit is named rather than vague: the first customer who needs one human in two organizations — an agency or a consultancy. Deciding it then costs a migration; discovering it after that tenant onboards costs their trust. Unblocks WS-29a, which is what gates the production ClickUp import. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/specs/multi_tenancy.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/ai-company-brain/specs/multi_tenancy.md b/ai-company-brain/specs/multi_tenancy.md index cd93daee..8e81b098 100644 --- a/ai-company-brain/specs/multi_tenancy.md +++ b/ai-company-brain/specs/multi_tenancy.md @@ -74,8 +74,17 @@ and an `ALTER` on live rows instead of a one-line default on empty ones. ### D-MT-1 — Can one person belong to more than one organization? -`DECISION — OWNER-ANSWER REQUIRED.` Everything else in this document is downstream of it, and -it is a product question about how the SaaS is sold rather than a technical one. +`DECISION (owner-delegated 2026-08-08).` Put to the owner with both options costed; the +answer was *"go ahead with what you think is right"*, so the recommendation below was +taken as the decision. **ANSWERED: (a) — one person, one organization, for v1.** +Everything else in this document is downstream of it. + +**This is the reversible direction, which is why it was safe to take.** (a) → (b) is a +migration plus an org-switcher, run once, while accounts are few. (b) → (a) takes a +capability away from people already using it. Given a delegated choice between a door +that stays open and one that closes, the open one wins — and the moment the product is +sold to an agency or a consultancy, revisit this before the first such tenant onboards +rather than after. * **(a) One person, one organization.** `app_user.email` stays globally unique. A request's tenant is *derived* from `X-User-Email`, so the identity seam every app already reads does @@ -90,11 +99,11 @@ it is a product question about how the SaaS is sold rather than a technical one. `pm_project_grants.subject` and `pm_task_assignees.assignee` are bare emails (D-PM-4), and under (b) a bare email no longer identifies a person. -**Agent's recommendation: (a) for v1**, because it preserves the `X-User-Email` seam the whole +**Chosen: (a) for v1**, because it preserves the `X-User-Email` seam the whole platform is built on and can be relaxed later behind an org-switcher, whereas (b) is a change -to identity itself and cannot be deferred once accounts exist. **This is a recommendation, not -a proposal** — if the intended customers are agencies or consultancies, (b) is right and it is -much cheaper to decide that now than after the first tenant onboards. +to identity itself and cannot be deferred once accounts exist. **The trigger to revisit is +named, not vague:** the first customer who needs one human in two organizations. Until then +`X-User-Email` alone resolves the tenant, and no app's auth seam changes. ### D-MT-2 — Where is isolation *enforced*? From ffc23d3eaf918c00768b7b716b1bbd58f816e993 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 20:44:09 +0000 Subject: [PATCH 10/22] fix(WS-25 D1): make the extracted deploy script actually shellcheckable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ I dispatched this against a stale premise and the agent corrected it. D1's extraction was already committed at c1eba71f — `DEPLOY_SCRIPT` appears zero times in deploy.yml, and spec §8.1 says so. I read the WS-25 work-plan row and §3, both of which describe the problem state, and did not read §8. The right response was to verify rather than redo, which is what happened: the pre-extraction env: value was reconstructed by YAML-parsing the old deploy.yml and diffed against scripts/vps_apply.sh — byte-identical, 437 lines, sha256 a779724d089319f6. What was NOT delivered, and is here: The extracted script was not shellcheckable, which is half of what D1 was for. Line 1 was `set -e`, because a YAML env: value fed to `bash -s` has no shell to declare — so shellcheck refused to analyse it at all (SC2148, error, exit 1, nothing checked). It was the only .sh in scripts/ without a shebang, a direct artifact of the extraction. Verified by stashing the fix and watching shellcheck go blind again. With a shebang it runs, and reported exactly one real finding: an unquoted `$(date +%s)` and `$deadline` in the healthcheck wait loop (SC2046). Fixed. `shellcheck scripts/vps_apply.sh scripts/vps_pull.sh` now exits 0 at default severity. `--enable=all` adds ~700 opt-in style notes, declined: rewriting 481 lines of production deploy logic for zero defect is the behaviour change this ticket exists not to make. The file stays non-executable (0644, matching vps_pull.sh). Both callers name the interpreter; +x would advertise a fourth way to start a deploy. And the spec gains a MEASURED table where it had a claim. The self-rewrite hazard was demonstrated, not asserted, against a throwaway repo — and it is worse than §3 described. The naive single-stage script fails three ways, and "executes garbage" is the RAREST: rename (what git reset --hard actually does): the fd keeps the old inode, all 12 steps run — v1's steps against a v2 tree — and it exits 0 in-place, shorter: resumes past EOF, remaining steps silently vanish, exits 0 in-place, shifted: resumes mid-token, `iet: command not found`, exits 127 Two of three exit 0, and git's own method is the quietest: HEAD correct, steps stale, nothing red. No exit-code check and no health probe catches it. The two-stage bootstrap was proven against the REAL scripts/vps_pull.sh, not an imitation: v2's twelve steps, in order, exit 0, markers written. I re-ran the harness myself rather than take the report on trust. Left for the owner, deliberately: all four WS-25 acceptance items remain OWNER-GATE, nothing was switched, and the push path still delivers by `bash -s` from stdin. Reported but not changed: ENV_FILE/WB_ENV are hardcoded to /opt/acb/app while APP_DIR is overridable, so a hand-run with APP_DIR=/sandbox would git-reset one tree and write secrets into the live box's .env — documented in place, since D1's whole point is that this file can now be hand-run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../specs/deploy_delivery_path.md | 34 +++++++++++++ scripts/vps_apply.sh | 51 ++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/ai-company-brain/specs/deploy_delivery_path.md b/ai-company-brain/specs/deploy_delivery_path.md index e5942efa..5b3e4d15 100644 --- a/ai-company-brain/specs/deploy_delivery_path.md +++ b/ai-company-brain/specs/deploy_delivery_path.md @@ -120,6 +120,22 @@ rewrites the file while bash is still reading it — bash reads scripts incremen by byte offset, so this executes garbage. The extraction must be two-stage: a small stable bootstrap that fetches, then `exec`s the fresh script. +⚠️ **"Executes garbage" was the optimistic guess. Measured 2026-08-08** — build a +throwaway origin with a 20 KB apply script whose first act resets its own +checkout, publish a second version, and run it both ways. The trap has *three* +outcomes and only one of them makes a noise: + +| How the file is replaced | What bash does next | Exit | +|---|---|---| +| **rename** — what `git reset --hard` actually does | the open fd keeps the OLD inode; every step runs, but they are the **old script's** steps against the **new** tree | **0** | +| in-place rewrite, new file shorter | resumes past EOF — the remaining steps **silently do not happen** | **0** | +| in-place rewrite, bytes merely shifted | resumes mid-token: `--quiet` → `iet: command not found` | 127 | + +So the failure git actually produces is the **quietest** one: exit 0, `HEAD` +correct, deploy steps stale. That is Defect 3 (§8.3) one level down — the tree +says it converged while the work never happened — and it is why no exit-code +check and no health probe can catch this. Only not running from the checkout can. + --- ## 4. Options @@ -254,6 +270,24 @@ This is what makes one script serve both delivery paths. A poller that carried its own copy would drift from the workflow's, and the drift would only surface during an incident. +**Amended 2026-08-08 — the byte-identical move left the file unshellcheckable.** +Line 1 was `set -e`, because a YAML `env:` value fed to `bash -s` has no shell to +declare. With no shebang and no `shell` directive, `shellcheck scripts/vps_apply.sh` +refuses to analyse the file at all — SC2148, *error* level, exit 1, nothing +checked — so half of what D1 was for was not actually delivered. Added +`#!/usr/bin/env bash` plus the WHY header; the line is inert on both delivery +paths (each names the interpreter, so `#!` is a comment) and buys the analysis +for no behaviour change. One real finding then fell out and is fixed: SC2046 at +the healthcheck wait loop, `[ $(date +%s) -lt $deadline ]` unquoted. +**`shellcheck scripts/vps_apply.sh` and `shellcheck scripts/vps_pull.sh` are now +clean at default severity, invoked with no flags.** + +The file stays **non-executable** (0644, matching `vps_pull.sh`): nothing execs +it by path, and a `+x` bit would advertise a fourth way to start a deploy that +no delivery path uses. Hand-run it as +`cd /opt/acb/app && APP_DIR=/opt/acb/app bash scripts/vps_apply.sh` — but see +§3's table first, and copy it out of the object database before you do. + ### 8.2 `scripts/vps_pull.sh` — the poller Three decisions in it are load-bearing: diff --git a/scripts/vps_apply.sh b/scripts/vps_apply.sh index 9fbf5a0b..38105e8f 100644 --- a/scripts/vps_apply.sh +++ b/scripts/vps_apply.sh @@ -1,3 +1,44 @@ +#!/usr/bin/env bash +# WS-25 D1 — the deploy steps, as a versioned file. +# +# This file was lifted BYTE-IDENTICALLY out of `.github/workflows/deploy.yml`'s +# `env.DEPLOY_SCRIPT` (437 lines, sha256 a779724d089319f6…). It is the single +# copy both delivery paths run: +# +# push path `.github/workflows/deploy.yml` — `ssh 'bash -s' < this file` +# pull path `scripts/vps_pull.sh` — `git show :this file | bash` +# +# One file, so the two paths cannot drift. Drift between them would only ever +# surface during an incident, which is the worst moment to discover it. +# +# ── The shebang is new, and it is the only thing that is ───────────────────── +# The extraction left line 1 as `set -e`, because inside a YAML `env:` value fed +# to `bash -s` there was nothing to declare a shell TO. That cost D1 half of its +# stated payoff: with no shebang and no `shell` directive, shellcheck refuses to +# analyse the file at all (SC2148, error) and exits 1 having checked nothing. +# The line is inert on both delivery paths — both invoke `bash ` or pipe +# into `bash -s`, where a `#!` is just a comment — so this buys the analysis for +# no behaviour change whatsoever. +# +# The file is deliberately left NON-executable (0644), matching vps_pull.sh. +# Nothing execs it by path; both callers name the interpreter. Marking it +x +# would advertise a fourth way to start a deploy that neither path uses. +# +# ── Running it by hand during an incident ──────────────────────────────────── +# cd /opt/acb/app && APP_DIR=/opt/acb/app bash scripts/vps_apply.sh +# +# ⚠️ but NOT from a checkout you are about to have rewritten. Step 0 below is +# `git reset --hard origin/main` — it replaces THIS FILE while bash is still +# reading it, and bash reads a script incrementally by byte offset. Measured, +# all three outcomes, none of which raise an alarm you would notice: +# • git replaces by RENAME, so the open fd keeps the old inode: every step +# runs, but they are the OLD file's steps against the NEW tree. Exit 0. +# • an in-place rewrite to a SHORTER file: bash resumes past EOF and the +# remaining steps silently do not happen at all. Exit 0. +# • an in-place rewrite that merely SHIFTS bytes: bash resumes mid-token +# (`--quiet` → `iet: command not found`). Exit 127. +# Copy it out first — `git show origin/main:scripts/vps_apply.sh > /tmp/a.sh` +# — and run THAT. This is what vps_pull.sh does, and why. set -e APP_DIR="${APP_DIR:-/opt/acb/app}" cd "$APP_DIR" @@ -17,6 +58,14 @@ fi echo "==> Skipping deprecated LiteLLM proxy cleanup (already removed)" echo "==> Ensuring memory-layer env vars (Neo4j disabled for low-memory VPS)" +# ⚠️ Hardcoded, while APP_DIR above is overridable — so is WB_ENV below. Noticed +# during WS-25 D1 and DELIBERATELY LEFT AS IS: on both delivery paths APP_DIR is +# /opt/acb/app, so "$APP_DIR/.env" would be the identical string today and +# changing it is a behaviour change, not a refactor. Named because D1's whole +# point is that this file can now be hand-run: `APP_DIR=/some/other/checkout` +# would git-reset one tree and then rewrite a DIFFERENT tree's .env, generating +# secrets into the live box while you thought you were in a sandbox. Until this +# is unified (owner's call), hand-run it only with APP_DIR=/opt/acb/app. ENV_FILE="/opt/acb/app/.env" for _var in MEM0_ENABLED GRAPHITI_ENABLED; do if ! grep -qE "^${_var}=" "$ENV_FILE" 2>/dev/null; then @@ -84,7 +133,7 @@ docker compose -f infra/docker-compose.yml --profile core up -d --remove-orphans echo "==> Waiting for healthchecks (up to 90s)" deadline=$(( $(date +%s) + 90 )) -while [ $(date +%s) -lt $deadline ]; do +while [ "$(date +%s)" -lt "$deadline" ]; do unhealthy=$(docker ps --filter "label=com.docker.compose.project=acb" --format '{{.Names}}\t{{.Status}}' \ | awk '$0 ~ /unhealthy|starting/ {print $1}') if [ -z "$unhealthy" ]; then break; fi From 1fdf95674f3e831a615e6f39ce52c25ac20deea9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 20:48:59 +0000 Subject: [PATCH 11/22] =?UTF-8?q?docs(WS-29):=20cross-tenant=20leak=20audi?= =?UTF-8?q?t=20=E2=80=94=20and=20a=20correction=20to=20my=20own=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ CORRECTION FIRST. This spec, the work plan and a commit message all said SIX tables carry organization_id. It is THREE. `crm_activities`, `crm_contacts` and `crm_deals` carry a column spelled `organization_id` that REFERENCES crm_organizations — a customer company, not the tenant root. Verified against pg_constraint on a live database. The CRM is unscoped like everything else, so the real figure was 140 unscoped, not 137. Two consequences, and the second is worse than the miscount: * the column name is TAKEN. Scoping crm_* needs a rename or a different name, and that has to be decided before WS-29d touches those tables. * test_tenancy_boundary.py — which I wrote and shipped two commits ago — matched on the column NAME. It counted the homonyms as scoped, which means any future table with an `organization_id` pointing anywhere at all would have passed the ratchet silently. A guard satisfiable by a coincidence of naming is not a guard. Fix lands with WS-29a, which currently holds that file. THE AUDIT. Four S1 findings, all of them paths a column-plus-predicate retrofit does not close — because the tenant now lives on `Visibility`, and every one of these is a path that never builds one: 1. The entire admin plane resolves its tenant from a hard-coded slug. get_org_id() does `WHERE slug = 'default'` and ignores the caller; 26 call sites — members, roles, groups, permission overrides, access requests, /auth/me. A tenant-B admin lists, invites into and grants roles in tenant `default`. Cross-tenant WRITE into access control, by a correctly-authorised caller. Confirmed by reading _common.py myself. 2. One credential set for the deployment. provider_keys.provider is the PRIMARY KEY, and migration 11 put Zoho/ClickUp/Gmail tokens in the same table; reads go through a module singleton keyed by provider alone, writes go to os.environ and the on-disk .env, which cannot be tenant-scoped in a shared process. LiteLLM's own organization_id is an UNRELATED namespace — do not connect them. 3. The event bus is global and the receiving workflow can write any task. Tenant A edits a task, tenant B's workflow fires, and it patches tenant A's task. Read and write, closed by nothing in the retrofit. 4. Agent tool identity is a process-global env var that is never cleared, so an agent can act as whoever ran last. Under D-MT-1 that string IS the tenant — and it is already a cross-USER bug today, not merely a future multi-tenant one. SAFE, with reasons, which is worth as much as the leaks: there is no object storage at all (no S3/MinIO/presigned URLs anywhere in first-party code — attachments are local disk, uuid4-named, never served by path), /projects/ search cannot be widened from the query string, pm_task_counters is per-root-project, and notifications resolve each recipient's own authority. Email and WhatsApp scope on user_id and are safe ONLY because D-MT-1 makes email globally unique — a cost that now belongs in D-MT-1's write-up. The audit also proposes a THREE-way baseline split over my two, and the argument is right: NEVER_SCOPED / DEPLOYMENT_GLOBAL / NOT_YET_SCOPED, because "this is deliberately global" is a decision and hiding it among "not done yet" is how it gets made by accident. Also generalises the import warning: INGESTION_CONSUMER=1 and CRM_ZOHO_SYNC=1 write unscoped rows UNATTENDED and are one env var away from doing so. The ClickUp import is merely the one with a button. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/specs/multi_tenancy.md | 33 +- .../specs/multi_tenancy_leak_audit.md | 710 ++++++++++++++++++ ai-company-brain/work_plan.md | 2 +- 3 files changed, 737 insertions(+), 8 deletions(-) create mode 100644 ai-company-brain/specs/multi_tenancy_leak_audit.md diff --git a/ai-company-brain/specs/multi_tenancy.md b/ai-company-brain/specs/multi_tenancy.md index 8e81b098..446f020a 100644 --- a/ai-company-brain/specs/multi_tenancy.md +++ b/ai-company-brain/specs/multi_tenancy.md @@ -18,19 +18,34 @@ which is a better starting position than it sounds and a worse one than it looks | | | |---|---| | App tables defined in migrations | **143** (plus `LiteLLM_*`, vendored, not ours) | -| Carrying `organization_id` | **6** | -| Carrying none | **137** | -| `pm_*` tables (Projects, WS-27) | 17 — **0 scoped** | - -The six that are scoped: `app_user`, `crm_activities`, `crm_contacts`, `crm_deals`, -`org_group`, `org_role`. +| Carrying a real tenant key | **3** | +| Carrying none | **140** | +| `pm_*` tables (Projects, WS-27) | 17 — **0 scoped** (WS-29a fixes this) | + +The three that are scoped: `app_user`, `org_group`, `org_role` — all +`REFERENCES organization(id)`. + +> ⚠️ **CORRECTED 2026-08-08. This document first said six, and it was wrong.** +> `crm_activities`, `crm_contacts` and `crm_deals` do carry a column spelled +> `organization_id`, but it `REFERENCES crm_organizations(id)` — a **customer +> company**, not the tenant root. Verified against the live database's +> `pg_constraint`. The CRM is unscoped, like everything else. +> +> **Two consequences, and the second is worse than the miscount.** First, the +> column name is *taken*: scoping the CRM needs a rename or a different name, +> and that must be decided before WS-29d touches `crm_*`. Second, +> `test_tenancy_boundary.py` matched on the column NAME, so it counted these +> homonyms as scoped — meaning any future table with an `organization_id` +> pointing anywhere at all would pass the ratchet silently. **A guard that can +> be satisfied by a coincidence of naming is not a guard.** It now matches on +> the foreign key's TARGET. **An `organization` table already exists** (migration 130) with `slug`, `display_name`, `domain`, `settings`, and exactly one seeded row — `slug='default'`. `app_user` gained `organization_id` in the same migration. So the spine of a tenant model is there; it was simply never carried past the access-control system and the CRM. -**This is not a Projects problem.** WS-27 is 17 of the 137, and the majority of the tree is in +**This is not a Projects problem.** WS-27 is 17 of the 140, and the majority of the tree is in the same position: every `gtd_*`, `email_*`, `wa_*`, `workflow*`, `app*`, `chat_*` table, and — tellingly — `org_settings`, `org_role_permission`, `user_role` and `org_group_member`. `org_settings` says so in its own comment: *"there is no per-tenant key namespace because this @@ -68,6 +83,10 @@ and an `ALTER` on live rows instead of a one-line default on empty ones. **The cost of waiting is a few days. The cost of not waiting is paid once per table, forever.** +**The same warning belongs on `INGESTION_CONSUMER=1` and `CRM_ZOHO_SYNC=1`**, which the leak +audit surfaced: both write unscoped rows *unattended*, and each is one environment variable +away from doing so. The ClickUp import is merely the one with a button. + --- ## 3. The decisions diff --git a/ai-company-brain/specs/multi_tenancy_leak_audit.md b/ai-company-brain/specs/multi_tenancy_leak_audit.md new file mode 100644 index 00000000..6cdedd00 --- /dev/null +++ b/ai-company-brain/specs/multi_tenancy_leak_audit.md @@ -0,0 +1,710 @@ +# Multi-tenancy — the leak paths a column-plus-predicate retrofit does not close + +> **Minted 2026-08-08**, adversarial read of the tree at `ccb762a8`, alongside WS-29a/b. +> Companion to `multi_tenancy.md`. Everything here is read off code and off a live Postgres 16 +> with the migration set applied; every claim carries a `file:line`. +> +> **Scope.** `multi_tenancy.md` costs the *database* half of the retrofit: a column on 17 +> tables and a predicate in one query. This document is the other half — the places where a +> request, a job or a process reaches another tenant's data **without going through +> `_VISIBLE_PROJECTS_SQL` at all**. Two such places were already known and are assigned +> elsewhere (`pm_project_grants.subject='org'`; `data:org:read` → `unrestricted`); neither is +> restated below except where a third path makes one of them sharper. + +--- + +## 0. The one-paragraph version + +The Projects retrofit is contained, WS-29a/b does it correctly (§3, S2-8), and it is contained +for a reason that does not generalise: **Projects has one visibility seam and nothing else in +CommandCenter does.** So the remaining leak surface is precisely the set of paths that never +build a `Visibility` at all — and they are the ones that matter most. The admin plane +resolves its organization from a hard-coded slug; LLM and integration credentials are one row +per provider for the whole deployment; the event bus fans every tenant's events into every +tenant's workflows; a workflow can then patch any task by raw UUID with the visibility check +*deliberately* removed; and the identity an agent's tools act under is a process-global +environment variable. Ranked below by blast radius. The measured `organization_id` count is +also wrong — §5. + +--- + +## 1. Findings, ranked by blast radius + +### S1-1 — The entire admin plane resolves its tenant from a hard-coded slug + +`apps/services/gateway/gateway/routes/admin/_common.py:102-118` + +``` +async def get_org_id(db) -> str: + """Resolve the deployment's organization id, or 503 if unprovisioned.""" + ... text("SELECT id::text AS id FROM organization WHERE slug = :slug"), + {"slug": DEFAULT_ORG_SLUG}, # _common.py:62 → "default" +``` + +The caller is never consulted. There are **27 call sites**, covering every write in the org +model: + +| surface | file:line | +|---|---| +| member list / invite / suspend / remove / roles | `admin/members.py:113,169,209,281,588,652,801,887` | +| group create / rename / delete / add / remove member | `admin/groups.py:180,207,258,296,371,436` | +| role CRUD + permission grants | `admin/roles.py:112,157,234,300` | +| access-request queue | `admin/access_requests.py:419` | +| `GET /auth/me` | `admin/me.py:111` | + +**What leaks.** The moment a second `organization` row exists, a tenant-B admin holding +`admin:members:*` lists **tenant `default`'s** roster, invites people **into** `default`, +creates groups **in** `default`, and grants roles **in** `default`. `GET /auth/me` +(`me.py:111-127`) reports the `default` organization's `slug`/`display_name` to every signed-in +member of every tenant, so the frontend's idea of "which org am I in" is wrong for all but one. + +This is worse than a read leak: it is an unbounded **write** into another tenant's access +control, performed by a caller the permission system correctly authorised — for their *own* +org, which the query then discards. + +**What closes it.** `get_org_id(db)` must become `org_of(user)` — a lookup keyed on +`UserContext.organization_id`, which `_with_resolved_access` already populates +(`packages/acb_auth/acb_auth/deps.py:272-275` via `resolve_identity`, `access.py:358-380`). The +27 call sites then inherit it. `DEFAULT_ORG_SLUG` should survive only as the *provisioning* +seed, never as a resolution. Until then this surface is single-tenant by construction and no +`pm_*` column changes that. + +--- + +### S1-2 — One set of LLM and integration credentials for the whole deployment + +`infra/postgres/08_provider_keys.sql:6-13` · `infra/postgres/11_integration_credentials.sql:17-27` +· `packages/acb_llm/acb_llm/key_store.py:57,120-137,431-438` + +```sql +CREATE TABLE provider_keys ( + provider TEXT PRIMARY KEY, -- "openai" | "zoho-crm:refresh_token" | "clickup:…" + encrypted TEXT NOT NULL, ... +``` + +`provider` is the **primary key** — globally, for the deployment. Migration 11 extended the +same table to hold *integration* credentials (`credential_type='integration'`), so Zoho, +ClickUp, Gmail and Apollo tokens share the namespace. Reads go through a module-level singleton +(`key_store.py:431-438`) whose in-memory cache is keyed by provider alone +(`key_store.py:57`, hit at `:120-122`) — no tenant dimension exists to key on. + +Writes are worse than shared, they are **process-global**: + +* `routes/settings.py:203` — `os.environ[env_var] = value` mutates the running process. +* `routes/settings.py:207-227` — `_sync_key_to_store` overwrites the single `provider_keys` row. +* `routes/settings.py:797-802` (`_write_env_key`) writes the on-disk `.env`. + +The gate is `require_permission("feature:models")` (`settings.py:592,719,756,794,947,997,1026,1047`), +which is a **per-user permission with a deployment-global effect**. `model_config` +(migration 35, `key TEXT PRIMARY KEY`) has the same shape for enabled/hidden models and tier +overrides. + +**What leaks.** Every tenant's completions bill the same provider key, so cost attribution is +impossible and one tenant can exhaust another's quota. A tenant-B admin can *replace* the +OpenAI key (silent MITM of every tenant's prompts) or replace the Zoho refresh token (pointing +tenant A's CRM sync at tenant B's Zoho, or vice versa). `GET /settings/llm/*` surfaces enough +to confirm which providers are configured across the deployment. + +**What closes it.** `PRIMARY KEY (organization_id, provider)`, an `organization_id` on +`model_config`, and — the part that is not a migration — deleting the `os.environ` /`.env` +write-through, which cannot be tenant-scoped in a shared process. The `_cache` dict must key on +`(org, provider)`. Whether *some* keys stay deployment-global (a platform-supplied model key, +with the tenant billed by usage) is a product decision that should be **made explicitly**, per +provider, rather than inherited from a schema written for one company. + +**LiteLLM.** The `LiteLLM_*` tables carry their own `organization_id`, and the two models are +**unrelated namespaces that happen to share a word**. There is no LiteLLM proxy in this +deployment — `settings.py:196-198` says so ("Since there's no separate LiteLLM proxy, keys are +set in the current process environment AND the encrypted Postgres key store"), the `LiteLLM_*` +tables appear only in `infra/postgres/schema.generated.sql` and are **absent from the live +database** (123 tables, none `LiteLLM_*`). Nothing connects the two org models and nothing +should; the ratchet is right to exclude the prefix (`tests/unit/test_tenancy_boundary.py:38`). + +--- + +### S1-3 — The event bus is global, and the workflow that receives an event may write any task + +Three files compose into one self-serve cross-tenant read **and write** chain. + +**(a) Dispatch matches on `source` + `event_type` only.** +`apps/services/gateway/gateway/routes/workflows/triggers.py:52-64` + +```sql +SELECT t.config, w.id AS workflow_id, ... + FROM workflow_triggers t JOIN workflows w ON w.id = t.workflow_id + WHERE t.kind = 'event' AND t.enabled + AND w.status = 'published' AND w.latest_version IS NOT NULL +``` + +No tenant, no owner, no filter beyond "published". `event_trigger_matches` +(`triggers.py:32-37`) compares `config["source"]` and `config["event_type"]` and nothing else. +`workflow_triggers` and `workflows` carry no tenant key +(`tests/unit/test_tenancy_boundary.py`, `workflow_*` block). + +**(b) Projects emits onto that same bus.** `routes/projects/core.py:1022-1044` (`emit` → +`ingestion.event_hooks.emit_event("projects", …)`), registered as a sink at +`gateway/main.py:1144-1147`. Sixteen emit sites, e.g. `projects/tasks.py:294,384,450,496,587`. +Payloads carry ids, not titles — that limits the *direct* exfiltration and is worth crediting. + +**(c) The receiving workflow's `pm_task` node has no visibility check, by design.** +`routes/projects/automation.py:26-33`: + +> *"**Who this acts as.** `system:workflow:` … and **not** member-scoped: there +> is deliberately no visibility check here. A published workflow is an org-level artifact."* + +`apply_task_patch` (`automation.py:110-180`) resolves the row with +`require_row(db, "pm_tasks", task_id, "Task")` at `automation.py:138` — a bare primary-key +lookup. `resolve_status` (`automation.py:90-96`) likewise reads `pm_task_statuses` by +`project_id` with no closure. The node is reached through `_pm_task_updater` +(`workflows/service.py:152-181`) and `_execute_pm_task` (`workflows/engine/handlers.py:234-262`). + +**The chain.** Tenant B publishes a workflow with an event trigger `{"source": "projects"}` and +a `pm_task` node whose `task_id` is `{{trigger.task_id}}`. Tenant A edits any task → `emit` → +`dispatch_event` → tenant B's workflow starts, run row `started_by="event:projects"` +(`triggers.py:88`) → the node patches **tenant A's task**: title, description, importance, +due date, estimate, status (`PATCHABLE_FIELDS`, `automation.py:55-57`). The run's step output +returns `{changed, status, skipped}` to tenant B, and the trigger payload it captured +(`triggers.py:81-85`) is readable in the run detail. + +That docstring is correct today and becomes the most dangerous sentence in the app the day a +second tenant onboards — the same shape as the `subject='org'` literal, one layer up. + +**What closes it.** Three things, none of which is the `pm_*` column: +1. `dispatch_event` must filter triggers to the emitting tenant, which means the **event needs + a tenant** — `emit` should carry `organization_id`, and `emit_event`'s sink signature + (`event_hooks.py:26`) should carry it too. +2. `apply_task_patch` must take a tenant (not a member) and scope `require_row` to it. Keeping + "not member-scoped" is right; "not tenant-scoped" is not. +3. The scheduler (`workflows/scheduler.py:106-120`) scans every enabled `schedule` trigger the + same way, under `started_by="schedule"` — same fix, same reason. + +--- + +### S1-4 — Agent tool identity is a process-global environment variable + +`apps/services/orchestrator/orchestrator/executor.py:1711-1721` and `:2185-2195` + +```python +if _mu: + _set_memory_user_id(_mu) + os.environ["ACB_AGENT_USER_EMAIL"] = _mu # never cleared +``` + +The ContextVar is correct. The `os.environ` write is a single slot in a shared async process, +and it is what the agents actually fall back to: + +* `apps/agents/agent-email-assistant/agents.py:62-75` +* `apps/agents/agent-crm/agents.py:76-87` +* `apps/agents/agent-whatsapp-assistant/agents.py:54-65` +* `apps/skills/skill-task-gtd/skill_task_gtd/core.py:84` + +Each `_current_user_email()` tries the ContextVar and falls back to the env var, with the +docstring explaining exactly why the fallback is load-bearing ("the Copilot SDK runs tool +callbacks in a context that can drop ContextVars"). Under D-MT-1 that email **is** the tenant. + +Two ways it goes wrong, and one is not hypothetical: + +* **Concurrency.** Two runs in flight from two tenants; the second's assignment wins for + whichever tool callback loses the ContextVar. The agent then reads the other tenant's mailbox + or CRM through the gateway with that email in `X-User-Email`. +* **Callers that set nothing.** `projects/agent_dispatch.py:144` calls + `run_agent(agent, message)` with a **string** payload, so the `isinstance(event_payload, dict)` + guard at `executor.py:1716` skips the assignment entirely and the variable keeps whatever the + previous run left. A WS-27f agent dispatch therefore acts as the last person to run an agent. + +**What closes it.** Delete the env-var fallback and fix the ContextVar propagation, or pass the +acting identity explicitly into the tool surface. No schema change helps. + +--- + +### S2-5 — `org` means "everybody in the deployment" in rooms and in session authority + +Distinct from the assigned `pm_project_grants` finding: these are different modules with their +own copies of the same literal, and no one is working on them. + +* `apps/services/gateway/gateway/rooms.py:368-402` — `SESSION_VISIBLE_SQL`. A room is visible + when a `chat_session_participant` row says `'org'`, or when `s.visibility = 'org'`, and the + only accompanying test is `EXISTS (SELECT 1 FROM app_user u WHERE u.email = :uid AND status + = 'active')` (`:387-401`). Any active member of **any** organization passes. `chat_session`, + `chat_message` and `chat_session_participant` carry no tenant key. +* `rooms.py:376-383` — the group branch joins `org_group g ON g.slug = substring(p.subject from 7)` + with no organization filter. +* `packages/acb_auth/acb_auth/access.py:400-402` — `_ORG_MEMBER_SQL` is literally + `SELECT email FROM app_user WHERE status = 'active'`, used at `:463` to expand an `org` + participant subject. +* `packages/acb_auth/acb_auth/access.py:392-398` — `_GROUP_MEMBER_SQL` matches `g.slug = :slug` + with no organization filter, used at `:466-470`. + +**The group-slug detail matters.** `org_group` is `UNIQUE (organization_id, slug)` — verified on +the live DB — so `engineering` is a *legal* slug in every tenant simultaneously. Every consumer +that matches on the bare slug therefore spans tenants the moment two orgs pick the same +obvious name. That includes the Projects grant vocabulary itself: `_MY_GROUPS_SQL` +(`routes/projects/core.py:406-414`) emits bare `'group:' || g.slug`, matched against +`pm_project_grants.subject` at `core.py:445`. The WS-29b tenant predicate on the grant closure +closes the Projects instance; it closes none of the others. + +**What closes it.** The `org`/`group:` expansion needs an organization argument in all four +places. Long term the subject vocabulary should carry the org (or the expansion should join +through `app_user.organization_id`), because "a bare slug identifies a group" stops being true +under multi-tenancy exactly as "a bare email identifies a person" would under D-MT-1(b). + +--- + +### S2-6 — An org-visible Custom App is visible to every tenant, and carries its data with it + +`apps/services/gateway/gateway/routes/apps/_common.py:270-293` + +```python +org_live = (_field(app_row, "visibility") == "org" + and _field(app_row, "status") == "live") +... +if org_live: + return True # any UserContext with an email +``` + +No organization check, and not even a `status='active'` check. `apps.visibility` is +`'private' | 'people' | 'org'` (`infra/postgres/114_custom_apps.sql:30-31`), and +`app_grants.subject` accepts `'org'` too (`114_custom_apps.sql:59-61`). + +`can_view` gates `require_app_viewer`, which gates the storage bridge: +`routes/apps/runtime.py:142-166` (`GET /{slug}/data/{table}`) and `:250-290` (`PUT`/`DELETE`) +read and write `app_data` rows in the **shared** partition (`user_scope = ''`, +`114_custom_apps.sql:73`). So a cross-tenant viewer does not just see the app, it reads and +writes the app's shared data store. + +Two smaller edges in the same table: `apps.slug` is `TEXT UNIQUE` globally +(`114_custom_apps.sql:25`), so tenant B can squat a slug tenant A wants and every app URL is a +global namespace; and workspace paths (`apps.workspace_path`) are a flat per-app directory with +no tenant segment (`routes/apps/files.py:87-96`). + +--- + +### S2-7 — The Action Broker queue is global, and approving executes + +`apps/services/gateway/gateway/routes/actions.py:47-55` → `action_broker/broker.py:246-263` + +```sql +SELECT id, actor, action, target, payload, authority, destructive, + disposition, status, created_at +FROM pending_actions WHERE status = 'pending' ORDER BY created_at DESC +``` + +`list_pending()` takes no argument and filters on nothing but status. `pending_actions` +(migration 66) has no tenant key. The route's own docstring notes the payloads carry +"outward-write bodies — CRM/email content". + +`approve(action_id, reviewer)` (`broker.py:340-358`) loads the row by id and runs the +registered handler; there is no check that the approver has any relationship to the proposal. +Handlers are a **flat, process-wide registry** (`broker.py`'s `register_action_handler`, wired at +`main.py:1140-1142` and five other sites), and each acts on the payload's own identifiers — +e.g. `workflow.resume_run` resumes `payload["run_id"]` verbatim +(`routes/workflows/broker_handlers.py:18-33`). + +**What leaks.** Anyone holding `feature:approvals` in any tenant reads every tenant's queued +outward writes (CRM record bodies, WhatsApp broadcasts, ClickUp comments) and can execute or +refuse them. Refusing is a denial-of-service on another tenant's automation; approving is a +write into another tenant's *external* system, which is the one place the platform cannot roll +back. + +**What closes it.** `organization_id` on `pending_actions`, set at `propose`/`enqueue` time +from the proposing principal, and a tenant argument on `list_pending`, `approve` and `reject`. + +--- + +### S2-8 — `pm_task_assignees` was a second door into a task — **CLOSED IN FLIGHT, verified** + +Recorded because it is the finding most likely to be *thought* covered by a closure-only +predicate, and because the next reader should know it was checked rather than assumed. + +`pm_task_assignees.assignee` is a bare email (D-PM-4), and `task_visibility_clause` / +`load_visible_task` grant access through it **without passing through +`_VISIBLE_PROJECTS_SQL`** — a deliberate escape hatch for cross-Center delegation. A tenant +predicate placed only inside the closure would not have reached it. Assignee writes are +unvalidated free text (`routes/projects/tasks.py:530-556` lowercases and inserts, with no check +that the address is a member of anything), so tenant A assigning `victim@tenant-b.example` +would have handed that person the task's title, description and full `pm_activities` timeline — +via `load_visible_task`, via `GET /projects/search` (`search.py:147`), and via the notification +bell, whose `deliverable()` (`notifications.py:143-175`) composes the same clause and whose row +snapshots an excerpt. + +**Verified closed** in the uncommitted WS-29b working tree, correctly and for the stated +reason. `routes/projects/core.py` now: + +* gives `Visibility` an `organization_id` that **fails closed on `None`** by construction + (`column = NULL` is never true) rather than by a check; +* resolves the tenant **before** consulting `data:org:read`, so the permission cannot widen a + caller out of their own organization; +* composes the tenant **above** the disjunction — + `({alias}.organization_id = :vis_org AND (grant-closure OR assignee-exists))` — and says in + its own docstring that the outer `AND` exists precisely to scope the assignee arm; +* deletes `load_visible_task`'s private copy of the two-armed predicate so the two cannot drift; +* replaces the `unrestricted → "TRUE"` short-circuit with the tenant in both clause helpers. + +`infra/postgres/158_projects_tenancy.sql:63-79,322-338` carries all 17 `pm_*` columns to +`NOT NULL`, with a `pm_organization_from_parent()` trigger (`:117`) so descendants inherit +rather than each INSERT site remembering. + +**What remains open here.** Assignee writes still accept any address. Nothing leaks now, but +`PUT /tasks/{id}/assignees` returns `not_notified` (`tasks.py:591-596`) — the list of addresses +that could not see the task. Post-retrofit, every out-of-tenant address lands in it, which +makes the field a cheap oracle for *whether a given email exists in this deployment*. Refusing +an out-of-tenant assignee outright is the honest fix and is cheap while the tables are empty. + +--- + +### S2-9 — Shared agents have one workspace and one blob partition for the whole deployment + +`packages/acb_skills/acb_skills/manifest.py:235-246`: + +```python +def instance_key(self, actor=None) -> str: + """'' (shared) · u: (personal) · t:""" +``` + +There is no `o:`. Every agent that has not declared `sharing.instancing='personal'` +resolves to `''`, which `agent_paths.py:136-149` maps to the **shared clone directory** and +`acb_memory/blob_store.py:101-132` maps to `agent_blob (agent_name, instance='', path)`. +`rehydrate_workspace` (`blob_store.py:345-396`) restores that partition onto a single on-disk +workspace, and its own docstring names the hazard: *"restoring the wrong instance would put one +person's notes in front of another."* + +The precedent is in the tree. `infra/postgres/137_quarantine_commingled_agent_data.sql:8-31` +exists because this exact failure already happened at the **user** level and had to be resolved +by quarantining data that could not be attributed. Multi-tenancy reintroduces it at the +organization level, for every agent that is not `personal`. + +The `t:` key does not help: `sharing.team` is a string in the agent's own repo +(`manifest.py:244`), so it is deployment-wide by construction. + +`agent_run` (the trace table) is likewise unscoped and enumerable — see S3-13. + +--- + +### S3-10 — Global tool/plugin registries reach every tenant's agents + +* `infra/postgres/13_mcp_servers.sql:8-19` — `name TEXT PRIMARY KEY`, plus + `agent_scope JSONB DEFAULT '["*"]'` and `headers JSONB` holding auth tokens. The executor + injects matching servers at agent-run time (file header, `:3-5`). +* `infra/postgres/14_plugins.sql:8-25` — `name TEXT UNIQUE`, `auth_config JSONB`, + `enabled BOOLEAN DEFAULT true`, tools auto-generated from the manifest and injected into the + agent's tool list. + +Both are single global namespaces with a default scope of "every agent". Registering an MCP +server or a plugin in tenant B makes it — and its credentials, and its egress — part of tenant +A's agent runs. Conversely a tenant's private MCP endpoint (with `headers` auth) is visible in +the registry to any tenant that can list it. + +`custom_api_definitions` (migration 12) and `app_tool_grants` (116) are in the same family; I +did not trace their read paths (see §4). + +--- + +### S3-11 — Public webhook receivers authenticate a *deployment*, not a tenant + +`gateway/main.py:486-508` (`PUBLIC_ROUTES`) exempts `/webhooks/clickup`, `/webhooks/gmail`, +`/webhooks/zoho`, `/agent/webhook/{source}` and the OAuth callbacks from +`require_authenticated`. Each verifies its own signature against a **single deployment-wide +secret**: + +* `ingestion/sources/clickup/webhook.py:23-29` — `get_settings().clickup_webhook_secret` +* `routes/agent.py:3433-3478` — `_webhook_secret(source)` / `AGENT_WEBHOOK_SECRET` + +A valid signature proves "somebody holds the deployment's secret", never "this is tenant A". +Since `POST /agent/webhook/{source}` calls `dispatch_event` directly +(`routes/agent.py:3529-3531`), a holder of that one secret can inject an event that fires every +tenant's matching workflows — the remote-trigger end of S1-3. + +--- + +### S3-12 — Jobs that run with no `X-User-Email`, and therefore no tenant + +Under D-MT-1 the tenant is derived from the caller's email, so anything without one has no +tenant. What each such path touches: + +| job | file:line | reaches | +|---|---|---| +| workflow schedule scanner | `workflows/scheduler.py:106-120` | every tenant's cron triggers; runs `started_by="schedule"` | +| workflow event dispatch | `workflows/triggers.py:52-64` | S1-3 | +| WS-27f agent dispatch sink | `projects/agent_dispatch.py:102-122` | `SELECT * FROM pm_tasks WHERE id = :tid` — no visibility, no tenant; then `run_agent` (S1-4) | +| ingestion consumer | `main.py:300-311` (`INGESTION_CONSUMER`, off by default) | drains `ingestion:{clickup,zoho,gmail}` into the same global sink registry | +| CRM ⟷ Zoho sync | `main.py:318-326` (`CRM_ZOHO_SYNC`, off by default) | writes the single Zoho tenant reached via the shared credentials of S1-2 | +| email sync scheduler | `email_ingestion/scheduler.py:1-13` | enumerates `email_accounts WHERE sync_enabled` globally, but writes only into each account's own rows — see §3 | +| WhatsApp enrichment | `whatsapp/scheduler.py:60` | `SELECT id FROM wa_accounts WHERE sync_status <> 'error'` — same shape, same verdict | +| GTD provider sync, calendar rollover | `main.py:258-278` | per-`user_id`; §3 | + +The two ingestion loops are gated off by default, which is the only reason they are S3 rather +than S1. **Turning either on before a tenant key exists is the same mistake as running the +ClickUp import**, and `multi_tenancy.md` §2 should say so about them too. + +Branch 1b of `get_current_user` (`packages/acb_auth/acb_auth/deps.py:373-384`) is the shape of +the problem: `UserContext(email="system:internal", role=AGENT, access=SERVICE_ACCESS)` — an +identity with every permission and, under D-MT-1, no organization. `resolve_identity` returns +`(None, None)` for it (`access.py:358-362`). **Whatever `resolve_visibility` does with a null +`organization_id` is the single most consequential line of WS-29b**: null-means-everything is a +silent global leak; null-means-nothing breaks every internal job until each is given a tenant. +Fail closed, and give the jobs an explicit tenant. + +--- + +### S3-13 — Enumeration surfaces without a tenant + +* `routes/debug.py:55-116` — `GET /debug/runs` selects from `agent_run` with only the filters + the caller supplies; `_ADMIN = require_role(EXECUTIVE, AGENT)` (`debug.py:26`). An executive + in any tenant enumerates every tenant's agent runs, with `user_id`, `agent_name`, `model`, + token counts and `error_message`; `GET /debug/runs/{run_id}` (`:120`) returns the full trace. +* `routes/actions.py:47` — S2-7. +* `/health` is genuinely empty ("Deliberately says nothing beyond status + env name", + `main.py:487-488`) — safe. + +### S3-14 — One sign-in domain for the deployment + +`packages/acb_auth/acb_auth/deps.py:204-230` — `allowed_email_domain()` reads a single +`ALLOWED_EMAIL_DOMAIN` (default `fracktal.in`) and `is_company_email` is the whole test on the +fail-open path (`deps.py:408`). `organization.domain` exists in migration 130 and **has no +reader anywhere in the tree**. A second tenant cannot express its own domain, so either the +check is disabled for everyone or the second tenant cannot sign in. Not a leak today; a +blocker the retrofit will hit on day one. + +--- + +## 2. The `organization_id` count is wrong — three of the six are homonyms + +Checked against the live database: + +``` + crm_activities | crm_activities_organization_id_fkey | REFERENCES crm_organizations(id) + crm_contacts | crm_contacts_organization_id_fkey | REFERENCES crm_organizations(id) + crm_deals | crm_deals_organization_id_fkey | REFERENCES crm_organizations(id) + app_user | app_user_organization_id_fkey | REFERENCES organization(id) + org_group | org_group_organization_id_fkey | REFERENCES organization(id) + org_role | org_role_organization_id_fkey | REFERENCES organization(id) +``` + +`crm_*.organization_id` points at **`crm_organizations`** — the *customer company* on a deal — +not at the tenant root (`infra/postgres/144_crm.sql:74,197,289`). The CRM is **not** tenant-scoped. + +Consequences: + +1. `multi_tenancy.md` §1's table should read **3 scoped / 140 unscoped** at the moment it was + written, not 6 / 137, and §1's list of "the six that are scoped" should drop the three CRM + entries. (With migration 158 applied the real figure becomes **20 scoped / 123 unscoped** — + 3 + the 17 `pm_*`. Both numbers should be restated together, or the correction will read as + the retrofit's doing rather than as a miscount that predated it.) +2. `tests/unit/test_tenancy_boundary.py:170-181` matches on the **column name only** + (`re.search(r"\borganization_id\b", …)`), so any future table with an `organization_id` + pointing anywhere at all passes the ratchet silently. The scan should resolve the FK target, + or at minimum assert `REFERENCES organization` on the same line. +3. `EXPECTED_SCOPED` (`:43-50`) asserts the three CRM tables are real tenant keys. They are not, + so the file currently claims coverage it does not have — the exact failure mode its own + docstring at `:150-155` warns about. +4. **The column name is taken.** Scoping `crm_contacts` to a tenant cannot reuse + `organization_id`; it needs `tenant_id`, or the CRM's column has to be renamed to + `account_id`/`company_id`. Decide this before WS-29d reaches the CRM family, not during. + +--- + +## 3. Paths checked and found SAFE — with the reason + +Recorded so nobody re-checks them. + +**Object storage — there is none, and that is the finding.** +There is no S3, no MinIO, no boto3 and no presigned URL anywhere in first-party code (`grep` +over `apps/`, `packages/`, `infra/` finds hits only under `.venv/`). Attachments are bytes on +local disk: + +* `routes/tasks/attachments.py:33-36` — `_storage_dir()` is one flat directory + (`GTD_ATTACHMENTS_DIR`, default `data/gtd_attachments`). +* `routes/tasks/attachments.py:64-67` and `routes/projects/attachments.py:113-116` — the + filename is `uuid4() + sanitised suffix`. **Unguessable in practice**, and the suffix is + allow-listed against `_BLOCKED_EXT`. +* Nothing is served by path. `routes/projects/attachments.py:182-222` serves only after a + database join proving the file hangs off a task the caller can see, and + `routes/tasks/attachments.py:91-108` serves only to `user_id = :uid`. `_safe_name` + (`tasks/attachments.py:39-41`) strips traversal. +* `routes/projects/attachments.py:22-28` — there is deliberately **no attach-by-id endpoint**, + so a caller cannot join somebody else's private capture onto their own task. + +Verdict: **SAFE.** One caveat worth writing down rather than acting on now: the directory is a +single flat namespace, so any future directory-listing or traversal defect leaks every tenant at +once, and backup/restore/export is not tenant-separable — a per-tenant subdirectory is cheap +now and expensive later. + +Two related points, both verified rather than assumed: + +* The serve route's old `if not vis.unrestricted:` guard (which dropped the predicate for + `data:org:read` holders and would have served every organization's bytes) is **already fixed** + in the WS-29b working tree — `projects/attachments.py:199-208` now appends + `vis.project_clause("t.root_project_id")` unconditionally. +* `gtd_attachments` — the row that holds the path — **did not get a tenant key** in migration + 158, and does not need one: the Projects serve route reaches it only by joining through + `pm_task_attachments` → `pm_tasks`, both now scoped, and the personal route + (`routes/tasks/attachments.py:99-102`) filters on `user_id = :uid`, which is per-tenant under + D-MT-1. It is on the S3 list only in the sense that a future third reader of that table would + have no key to filter by. + +`agent_blob` and `app_files` are **Postgres BYTEA/text columns, not object storage** +(`infra/postgres/71_agent_blob_store.sql`, `115_app_files.sql`), reached only through +`blob_store.py` and `routes/apps/durability.py`. Their exposure is S2-6 and S2-9, not a key +namespace. + +**`GET /projects/search` inherits the predicate.** `routes/projects/search.py:145-158` calls +`resolve_visibility` then composes `task_visibility_clause(vis)` into `_SEARCH_SQL`'s +`{visible}` slot (`:113,147`) — the same function `list_tasks` and `load_visible_task` use. +There is no second copy of the closure and no way to widen it from the query string: `q` is +`like_escape`d (`:141`), `limit` is clamped to `MAX_HITS` (`:140`), and `#123` parses to a +bounded bigint or `None` (`:66-76`). **SAFE by inheritance** — with the two inherited holes, +which are S2-8 (the assignee branch) and `data:org:read` (where the clause is literally `TRUE`, +`core.py:601-602`). Search is the highest-leverage way to exploit both, because it is the one +endpoint that returns ranked titles across everything at once; it should be re-tested against +both after WS-29b lands. + +**`pm_task_counters` / `task_number`.** `PRIMARY KEY (project_id)` referencing +`pm_projects(id) ON DELETE CASCADE` (`infra/postgres/146_projects.sql:182-185`), incremented by +a single `INSERT … ON CONFLICT DO UPDATE … RETURNING` under the caller's transaction +(`routes/projects/core.py:832-846`) keyed on `root_project_id`. Numbers are **per root project**, +so they are per-tenant for free once projects are; a wrong-tenant counter row is not reachable +because the key is the project id, and cross-tenant collision is meaningless. **SAFE** — it +needed no key of its own, and migration 158 gives it one anyway +(`158_projects_tenancy.sql:67,326`), which is D-MT-3's uniformity argument and is the right +call: an unindexable exception in a set of 17 is how the exception gets forgotten. + +**Email and WhatsApp.** Both scope on `user_id`, which is the email address — +`email_accounts` / `wa_accounts` and every read through them +(`routes/email/automation/replyzero.py:182`, `routes/whatsapp/core.py:170`, +`whatsapp/digest.py:88`, `whatsapp/pulse.py:99`, and ~20 more). Under **D-MT-1 email is +globally unique**, so per-user scoping is per-tenant scoping. **SAFE — but only because of +D-MT-1.** If D-MT-1 is ever revisited to (b), this entire family becomes unscoped in one step, +and that is a cost that belongs in the D-MT-1 write-up. + +**Projects notifications.** `routes/projects/notifications.py:143-175` resolves each recipient's +own authority through `resolve_visibility_for` (`core.py:497-556`), which goes through the real +`build_access`, then tests the task with `task_visibility_clause`. It inherits the tenant +predicate correctly and does not re-derive the closure. **SAFE by inheritance** (subject to S2-8). + +**Workflow webhook hooks.** `routes/workflows/hooks.py:60-79` looks the workflow up by an +unguessable per-workflow `hook_token` and verifies HMAC over the body against that workflow's +own secret (`:51-58`). One token, one workflow. **SAFE** — and the model the shared +deployment-wide secrets of S3-11 should be moved to. + +**The access cache.** `packages/acb_auth/acb_auth/access.py:37,47,86-98` — 60s TTL keyed by +lowercased email, invalidated on every admin write (`:76-83`). Email is globally unique under +D-MT-1, so the key is already tenant-unique. **SAFE.** Same for `resolve_access`'s SQL +(`:180-194`), which joins `user_role → org_role_permission` and therefore inherits `org_role`'s +existing tenant key. + +**Auth header trust.** `deps.py:296-412` — a bare `X-User-Email` is refused when an internal +token is configured (`:396-401`), and the LLM key can be refused as identity +(`:170-201`). The tenant is derived from an email that only the Next.js proxy can assert. +**SAFE as an identity seam**, which is what makes D-MT-1(a) cheap. Note the residual documented +at `deps.py:27-35`: the public vhost does not yet strip `X-User-*` (`deploy/hostinger/caddy/Caddyfile`), +so the whole tenant boundary rests on an owner action that has not been taken. That is a +pre-existing item, not a new finding, but multi-tenancy raises its severity from +"cross-account" to "cross-organization". + +**LiteLLM.** Vendored, absent from the live database, no proxy in this deployment. Its +`organization_id` is unrelated to ours. **SAFE to ignore; do not connect the two.** + +--- + +## 4. What I could not determine + +Stated plainly, so nobody reads silence as clearance. + +1. **~~The shape of the in-flight WS-29a/b change~~ — resolved.** I read the uncommitted + working tree (`routes/projects/core.py`, `attachments.py`, `infra/postgres/158_projects_tenancy.sql`) + and verified the predicate lands on `Visibility`, above the disjunction. S2-8 and the + attachments caveat are rewritten accordingly. Everything else in §1 was read from files WS-29b + does **not** touch: `automation.py`, `search.py`, `notifications.py`, `tasks.py`, + `agent_dispatch.py`, and everything outside `routes/projects/`. + + **This sharpens the whole document.** With the tenant now living on `Visibility`, the leak + surface is exactly *the paths that never build one*. Every S1 and S2 finding above is such a + path: `get_org_id` builds its own answer from a literal; `apply_task_patch` and + `agent_dispatch.on_event` take a raw task id; `list_pending` takes nothing; `can_view`, + `SESSION_VISIBLE_SQL` and the key store never touch Projects at all. A useful review + question for anything new is simply: *does this code path construct a `Visibility`, and if + not, what is its tenant?* +2. **The ingestion consumer's drain semantics.** I read the receivers + (`ingestion/sources/*/webhook.py`) and the sink registry, not `ingestion/consumer.py`'s + full XACK/retry path. It is off by default (`INGESTION_CONSUMER`); I have not verified what a + replayed or dead-lettered event does with respect to tenancy. +3. **Mem0 / graphiti memory partitioning.** `manifest.memory_scope` produces + `agent:#` (`manifest.py:248-256`), which has the same missing-org dimension + as S2-9 — but I did not trace the Mem0 client or `add_episode` (`main.py:1473-1474`) to + confirm whether the scope string is actually honoured as a partition boundary, or whether + there is a second key underneath it. Treat S2-9 as covering files, and memory as unverified. +4. **`custom_api_definitions` (migration 12), `app_tool_grants` (116) and the app tool bridge.** + Named in S3-10's family by schema shape only; I did not read their enforcement paths. +5. **Meeting bot, Note Taker and `live_session`.** Not examined. `meeting*`, `notes_glossary`, + `transcript_segment` and `live_session` are all unscoped; whether any of them has a global + read surface is unknown. +6. **The frontend.** `workbench/control_plane` was out of scope; if any org identity is derived + client-side it would compound S1-1's wrong `/auth/me` answer. + +--- + +## 5. Proposed: split the ratchet baseline + +`BASELINE_UNSCOPED` (`tests/unit/test_tenancy_boundary.py:53-153`) currently conflates "debt" +with "correct as is", which overstates the 137 and invites somebody to eventually "fix" +`organization` by giving it an `organization_id`. Proposing **three** sets rather than two, +because the third is a decision and not debt, and hiding it inside either of the others is how +it gets made by accident. + +**`NEVER_SCOPED` — a tenant key here would be nonsense. Membership I can defend from code:** + +| table | why | +|---|---| +| `organization` | the tenant root itself (migration 130) | +| `schema_migrations` | the migration ledger; `filename` PK, infrastructure (`153_schema_migrations.sql:24-36`) | +| `feature_catalog` | a catalog of *what features exist*; who gets them lives in `org_settings`, `org_role_permission` and `user_permission_override` (`140_center_features.sql:15-31`) | + +I deliberately kept this set to three. Everything else I considered failed the test "would a +tenant key here be actively wrong?" — including `audit_event` and `access_request`, which read +like infrastructure but are not (below). + +**`DEPLOYMENT_GLOBAL` — shared on purpose, and each entry needs a named owner decision:** + +| table | the decision that has not been made | +|---|---| +| `provider_keys` | does the platform supply LLM keys and bill usage, or does each tenant BYOK? Today: shared, silently (S1-2) | +| `model_config` | is the enabled-model catalogue a platform choice or a tenant choice? | +| `mcp_servers`, `plugins` | is the tool registry curated by the platform, or self-serve per tenant? Today: self-serve *and* shared, which is the worst pair (S3-10) | +| `copilot_config` | not examined; grouped by shape | +| `access_request` | a knock from an address with no org yet — genuinely has no tenant at knock time, but *some* tenant's admin must see it. Needs a routing rule (domain? invite token?), not a column | + +Being in this set must mean "we chose this", with the reason in the file. It must not mean +"nobody has looked". Every row above is a live finding in §1 or §2. + +**`NOT_YET_SCOPED` — real debt; the remaining ~130.** Two members worth calling out as *not* +belonging in `NEVER_SCOPED` even though they look like it: + +* `org_group_member`, `org_role_permission`, `user_role` — reachable through a scoped parent, + which is exactly the derivation **D-MT-3 rejects** (`multi_tenancy.md` §3). They carry the + key like everything else. +* `audit_event` — an audit trail is per-tenant evidence, not infrastructure. One tenant reading + another's audit log is a leak in its own right. + +And the three CRM homonyms (§2) must move **out** of `EXPECTED_SCOPED` and **into** +`NOT_YET_SCOPED`, with `test_the_expected_scoped_set_is_real_not_aspirational` tightened to +check the FK target rather than the column name. + +--- + +## 6. Suggested sequencing against `multi_tenancy.md` §5 + +Nothing here changes WS-29a's urgency. What it changes is what "done" means. + +| | | depends on | +|---|---|---| +| **WS-29a/b** | in flight and **verified correct** — 17 columns, the predicate on `Visibility` above the disjunction, `unrestricted` scoped, the assignee arm covered (S2-8) | — | +| **WS-29e (new, urgent)** | `get_org_id` → caller-derived (S1-1). 27 call sites, one function. Blocks any second tenant existing at all | — | +| **WS-29f (new, urgent)** | tenant on the event (S1-3): `emit` carries it, `dispatch_event` filters on it, `apply_task_patch` scopes `require_row` | WS-29a | +| **WS-29g (new)** | credentials: `PRIMARY KEY (organization_id, provider)`, drop the `os.environ`/`.env` write-through (S1-2) | product decision on BYOK | +| **WS-29h (new)** | delete the `ACB_AGENT_USER_EMAIL` fallback (S1-4) — no schema change, and it is a live cross-*user* bug today | — | +| **WS-29c** | RLS. **The strongest argument for (a) in D-MT-2 is this document**: every finding above is an application path that forgot. RLS is the only option where forgetting fails closed — provided the jobs in S3-12 get a GUC | D-MT-2 | +| **WS-29d** | the remaining families, largest blast radius first: rooms/chat (S2-5), apps (S2-6), broker (S2-7), agent blobs (S2-9) | WS-29c | + +**One addition to §2's red warning.** `POST /projects/import/clickup` is correctly gated. The +same gate belongs on **`INGESTION_CONSUMER=1`** and **`CRM_ZOHO_SYNC=1`** (`main.py:300-326`): +both write unscoped rows unattended, and both are one environment variable away from doing so. diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index aaf054d4..d49188cc 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -148,7 +148,7 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`. | WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. | | WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. | | **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT + DEPLOYED** · ✅ **d read half BUILT + DEPLOYED** · ✅ **D2 = d-email BUILT 2026-08-07** (branch `ws-26d-email-timeline`, merged) · 🟢 **d-autolead, d-write dispatchable** · ✅ **D1 = f BUILT 2026-08-07 (branch `ws-26f-pipeline-truth`, NOT run against prod)** · ✅ **D3 = g BUILT 2026-08-07 (branch `ws-26g-reports`, no migration)** · 🟢 **DEMO CRITICAL PATH (owner-directed 2026-08-07, spec §9.0): ~~D1 f~~ (∥ D2 d-email) → ~~D3 g~~ → D4 d-write → D5 d-autolead** · 🟡 **h/i/e deferred past the demo; i spec-thin** | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **migration 144 applied on prod and `/crm` live as of 2026-08-06**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Measured 2026-08-06: mig 145 is applied on prod and the BACKFILL HAS RUN — 737 orgs / 1,189 contacts / 1,516 leads / 551 deals / 1,909 notes, zero dirty rows, zero unmatched owners; the §7.1 pre-flip curl confirmed the tenant honors RFC-1123 `If-Modified-Since` (304). The PUSH direction has still never run: `CRM_ZOHO_SYNC` ships OFF, nothing has ever written the live Zoho tenant, and enabling the flag or hand-running a push cycle against prod stays OWNER-GATE §6.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Deployed:** migrations 144 and 145 are applied on prod as of 2026-08-06 and `/crm` is live, so live rendering, drag persistence and deep links are owner-verifiable now) · **d** integrations — **audited 2026-08-06 GO-NARROWED and the narrowed slice is BUILT** (branch `ws-26d-agent-crm`): `apps/agents/agent-crm/` (`crm-assistant`, MAF, four READ tools over the existing `/crm` routes carrying the caller's `X-User-Email`, read-only enforced at the transport by a GET-only method allowlist) registered in `_KNOWN_AGENTS` + `_AGENT_REGISTRY` + `agent_registry.json`, plus `"crm"` added to the WhatsApp `_KNOWN_SYSTEMS` allowlist **parse-only** (nothing writes `wa_contacts.entity_ref`, the `crm` context block stays `None`, both pinned by test). **The three held-back items are now DISPATCHABLE — their doc blockers (B3/B4/B5/B7) were closed 2026-08-06 in `crm_app.md` §9.1-§9.3, every anchor read off `origin/main` rather than recalled:** **WS-26d-email** (the timeline join is CALLER-scoped, never record-scoped — it reuses the email app's `_account_scope` predicate, copied into `routes/crm/` rather than imported per D-CRM-4, joins by thread not message, inbound `from_address` only, and needs a new address index at the next free migration number) · **WS-26d-autolead** (hook = `routes/email/scheduler_hooks.py::process_new_mail`, the one seam scheduler+manual+webhook all funnel through; the per-message rules loop was considered and REJECTED because a classifier outage there double-fires and history backfills never reach it; unknown-sender test mirrors `_maybe_block_cold`, colleague suppression via `is_own_mail`) · **WS-26d-write** (`request_confirmation` at the top of each tool, fail-closed, no `non_interactive_default="approve"`; `@_annotate_risk` is the shared annotation convention and is NOT enforcement; the Action Broker covers the Zoho push, not the native write — the two fail in OPPOSITE directions and are not interchangeable). The push-queue question is CLOSED — **D-CRM-9 (owner, 2026-08-06): agent-originated writes queue for Zoho exactly like human ones** (🟡 remainder; the flip stays OWNER-GATE) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. **Pipeline blueprint added 2026-08-07 (spec §5.1) after the owner's first live board session found lanes out of order and imported stages at 0% probability — root cause: the importer appends unseen Zoho stages past the seeds at probability 0, and 144's seeds renamed Zoho's defaults so name-match missed them; the admin API that could fix it is headless.** New tickets: **f** pipeline truth + settings UI — **BUILT 2026-08-07** (branch `ws-26f-pipeline-truth`, **no migration**: 144 already carried `position`, `probability`, `type`, `closed_at` and `expected_close_date`). `routes/crm/stage_metadata.py` = `POST /crm/import/zoho/stages`, floor `admin:access:manage`, **dry-run by default** and `?apply=true` to write; **>1 pipeline STOPS before the DB is opened** (D-CRM-11); f4's `closed_at` proxy is a direct UPDATE that bypasses `mark_dirty_on_update`, asserted statically against the statement text AND the module's call graph because the shared fake writes only what a SET clause names. Two settings readers on the Zoho read client with `ZohoScopeError`/`ZohoApiVersionError` so **no-scope, no-data and no-such-endpoint are three different reported outcomes** — and no-scope is the *expected* first answer, since the tenant's refresh token was never minted with `ZohoCRM.settings.*`. D-CRM-10's clamp landed in `admin.py::_validate_status` reading the ROW rather than the payload (a PATCH naming only `type`, or only `probability`, contradicts the rule only in combination with what is stored). `?tab=settings` is the headless-API fix and needs no Zoho token at all. **Nothing has been run against the tenant — dry run included.** · **g** forecast & funnel reports off `crm_status_changes` — **BUILT 2026-08-07** (branch `ws-26g-reports`, **no migration**: 144's `crm_status_changes` already carried every column). `routes/crm/reports.py` = four read-only endpoints (`/crm/reports/{pipeline,funnel,win-loss,owners}`) on the shared gated router, plus `?tab=reports`. **`WEIGHTED_SQL` lifted into `core.py`** (pipeline re-exports it) so the forecast formula has ONE definition, and `core.status_wire` absorbed two duplicate status projections instead of gaining a third. **The cross-language parity mechanism is minted here, not inherited** — the `priority.ts ⟷ priority.py` "precedent" is two hand-kept mirrors joined by a comment with no shared fixture anywhere: `tests/fixtures/crm_weighted_parity.json` (new directory) is read by BOTH pytest (through the emitted SQL, whose expression `_crm_fakes._WEIGHTED_SUM_RE` parses out of the statement text) and vitest (through `board.ts::weightedDeal`/`weightedRows`). ⚠️ **The funnel is defined against what the log RECORDS, not what its name suggests**: `crm_status_changes` logs transitions only, so all 551 imported deals have zero rows and "entered" is a VISITED-SET union (`from_status`, `to_status`, and the deal's current stage) — a `to_status` count reports an empty funnel for the whole live board; dwell is grouped by **`from_status`** (the stage being LEFT, the opposite key from the naive reading); the log stores NAMES, so a renamed lane orphans its history and orphans are REPORTED in `unmatched`, never dropped; and NULL `closed_at` — every imported closed deal until f4's owner-gated backfill runs — falls outside the trailing window, with the count reported so a 0% win rate is explicable rather than mysterious. The lost-reason breakdown carries a NAMED unattributed bucket: the earlier "complete by construction" claim is FALSE, since the importer bypasses both gates and `lost_reason_id` is `ON DELETE SET NULL`. **No `GROUP BY` is emitted** — the ticket asked for the choice to be stated: per-key aggregates in `get_pipeline`'s shape, because the weighted expression binds the lane's own default as `:stage_probability` and a grouped statement would stop BEING the expression the fixture and the fake read. 47 hermetic cases + 20 vitest + the 14-row shared fixture on both sides, 5 mutants red. **Two findings:** the `entity_type='deal'` mutant initially SURVIVED (the funnel keys through deal ids, so a lead row is excluded twice over) — closed with a row stamped `lead` against a DEAL's id, which is realistic precisely because `entity_id` has **no FK**; and `_crm_fakes`' `lower(col) = :param` reader matched a NULL column, which SQL never does, making the unassigned-owner bucket count rows its own aggregate never summed · **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views (🟡 spec-thin, audit-narrow first). **Demo path (2026-08-07, spec §9.0): full chain + all gates intact, tickets re-sequenced not thinned; f gained f4 — imported won/lost deals have no `closed_at` (importer never stamps it), backfilled from Zoho `Closing_Date` as a labeled proxy via a direct UPDATE that MUST bypass dirty-marking or ~500 no-op pushes queue for the live tenant.** | -| **WS-29** | **Multi-tenancy — isolating organizations** *(minted 2026-08-08)* | `specs/multi_tenancy.md` | 🔴 **D-MT-1 OWNER-ANSWER REQUIRED** · 🟢 ratchet in place | **Measured 2026-08-08, not recalled: 143 app tables, SIX carry `organization_id` (`app_user`, `crm_activities`, `crm_contacts`, `crm_deals`, `org_group`, `org_role`), 137 carry none — including all 17 `pm_*`.** An `organization` table has existed since migration 130 with one seeded row (`slug='default'`) and `app_user.organization_id`; tenancy was started and never carried past access control and the CRM. ⚠️ **`app_user.email` is globally UNIQUE, so today one person = one organization structurally — D-MT-1 asks whether that stays true, and everything else is downstream of the answer.** Projects is cheaper than its size suggests: 128 `FROM`/`JOIN` references to `pm_*` but **one** closure query (`_VISIBLE_PROJECTS_SQL`), so the retrofit is a column on 17 tables, a predicate in one query, and one line in the `Visibility` resolver. 🔴 **Blocks WS-27's production ClickUp import** (§6 gate (a)): importing a real workspace into 17 unscoped tables turns a one-line default on empty tables into a backfill on live rows. `tests/unit/test_tenancy_boundary.py` freezes the 137 and fails any NEW unscoped table — a ratchet, not a demand for the retrofit. Sequence in spec §5: **WS-29a** (`pm_*` key, urgent, gates the import) → **b** (tenant predicate) → **c** (RLS behind a flag) → **d** (the remaining 120 by family). | +| **WS-29** | **Multi-tenancy — isolating organizations** *(minted 2026-08-08)* | `specs/multi_tenancy.md` | 🔴 **D-MT-1 OWNER-ANSWER REQUIRED** · 🟢 ratchet in place | **Measured 2026-08-08 — and CORRECTED the same day: 143 app tables, **THREE** carry a real tenant key (`app_user`, `org_group`, `org_role`, all `REFERENCES organization`), 140 carry none — including all 17 `pm_*`.** ⚠️ This row first said six: the three `crm_*` tables carry a column *spelled* `organization_id` that references `crm_organizations`, a CUSTOMER COMPANY, not the tenant. Found by the leak audit, verified against `pg_constraint`. Consequences: the column name is taken (scoping `crm_*` needs a rename, decide before WS-29d), and the ratchet matched on column NAME so a homonym pointing anywhere passed silently — it now matches the FK target. An `organization` table has existed since migration 130 with one seeded row (`slug='default'`) and `app_user.organization_id`; tenancy was started and never carried past access control and the CRM. ⚠️ **`app_user.email` is globally UNIQUE, so today one person = one organization structurally — D-MT-1 asks whether that stays true, and everything else is downstream of the answer.** Projects is cheaper than its size suggests: 128 `FROM`/`JOIN` references to `pm_*` but **one** closure query (`_VISIBLE_PROJECTS_SQL`), so the retrofit is a column on 17 tables, a predicate in one query, and one line in the `Visibility` resolver. 🔴 **Blocks WS-27's production ClickUp import** (§6 gate (a)): importing a real workspace into 17 unscoped tables turns a one-line default on empty tables into a backfill on live rows. `tests/unit/test_tenancy_boundary.py` freezes the 137 and fails any NEW unscoped table — a ratchet, not a demand for the retrofit. Sequence in spec §5: **WS-29a** (`pm_*` key, urgent, gates the import) → **b** (tenant predicate) → **c** (RLS behind a flag) → **d** (the remaining 120 by family). | | **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e + i BUILT 2026-08-06 · j + k + l + m + n + o + p + s BUILT 2026-08-07 · q + r + t BUILT 2026-08-08 — the ClickUp parity backlog (§11.2) is now CLOSED** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced · ✅ **t BUILT 2026-08-08** (D-PM-11 + D-PM-12 answered, gate (e) cleared) | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g — and n is now BUILT (2026-08-07), so that dependency is SATISFIED**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end. g itself stays 🔴 OWNER-GATE for its own reasons (§6); this changes the prerequisite, not the gate · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store).. **j BUILT 2026-08-07** (mig `152_projects_notifications.sql`, `routes/projects/notifications.py`, the header bell + mention picker; 39 hermetic + 27 vitest cases, 10 mutants red): closes §11.2's second gap — *"assignment is silent"*. **Three rules decide who hears**, each the whole reason for a rule: never the actor (a bell that pings you about your own click gets muted, and a muted bell notifies nobody about anything); never an agent (they are handed work by the WS-27f dispatch sink, so a row addressed to one sits unread forever inflating a badge nobody can clear — enforced in Python AND by a CHECK); and **never somebody who cannot open it**, which is the security property: the notification carries the task's TITLE, so delivering one outside the grant closure leaks it and lands them on a 404. That third rule needed `resolve_visibility_for`, which answers for a THIRD PARTY — `resolve_visibility` reads a `UserContext` and the recipient of a mention has no request in flight — by reading the tables `/auth/me` reads and handing them to the **real** `build_access`, so wildcards and allow/deny overrides resolve identically on both paths. Notifications are written **inside the transaction**, not emitted on the bus: `emit` swallows failures by construction so a broken workflow can never fail a task edit, which is right for agent dispatch and wrong here. A mention is an **address, not a name**, because 148 dropped `UNIQUE(name)` — `@Priya` has no answer. **Two bugs found on the way in, both shipping at the time:** every project-task file upload was answering **422** (`ACTIVITY_TYPES` never learned 150's `attachment`; all 25 attachment tests passed because they monkeypatch `record_activity` — the seam under test was mocked out), fixed with two tests that READ the migrations; and `/projects?task=` did nothing, though the People Center has linked there since WS-28b. **WS-27b's UI BUILT 2026-08-07** (`components/ImportClickUp.tsx`, `lib/importPlan.ts`; 18 vitest cases, 3 mutants red): the importer shipped with WS-27b and was **unreachable from the product** — the empty state named "import a ClickUp workspace" and no control anywhere did it, so a new install stayed empty and the only route to real data was curl. Three steps, only the last of which writes: Preview (`/plan`, reads the tenant) → Dry run (`dry_run:true`, exercises the flattening) → Import. **The mapping stays the owner's act (D-PM-10)** and the UI is built so it stays one: the suggestion is pre-filled and shown beside its confidence IN WORDS ("a guess — check it", not `0.45`, because a bare number invites acceptance without looking), and a CONFIRMED mapping always beats a fresh suggestion so a re-run never silently re-maps a Space somebody already ruled on. Unmapped Spaces are a notice rather than a blocker, matching the importer. ⚠️ The gate is unchanged and is now exactly one click: building this was agent-safe, pressing Import is the owner's act, and no agent has run either endpoint against production. **The Tasks-app mirror path BUILT 2026-08-07** (`routes/projects/import_tasks.py`, `POST /import/from-tasks`; 43 hermetic cases, 7 mutants red) — owner-directed: *"just show up all the data that is there in the Tasks app inside the Projects app"*. A SECOND importer rather than a flag: the ClickUp one needs a live token, spends LLM budget and demands a Center mapping BEFORE anything is written, which is backwards for "show me my work today". This reads `gtd_projects`/`gtd_items` — the mirror already on the box — so no API call, no token, no model spend, and it works when the connector is stale. One named department, with the real ClickUp shape beneath it — **Space → Folder → List** rebuilt as projects, each carrying its own `clickup_id`/`clickup_kind`, so promoting a Space node is how the department split happens later. **Verified against a real Postgres** (full migration set + seeded mirror), which found THREE defects the hermetic suite could not: `gtd_projects.space_id` is LOCAL-only so the Space was always NULL; `pm_projects` has no `clickup_snapshot` column, so the first real click would have 500'd (this shipped in #393 and was fixed before anybody pressed it); and the preview under-counted 4 vs 7 because container nodes were only tallied on the write path. The root IS org-granted (same act as `create_node`, narrower than bulk-granting a tenant). Pinned: nothing outside `pm_*` is written; only `source <> 'LOCAL'` rows are read (a personal capture must not be published to a shared board); the provider's own status names are kept; an orphaned task is COUNTED, not dropped. **Mutation found a real gap**: deleting `dry_run` from the write guards left every test green, because on a FIRST dry run `root_id is None` blocks the write anyway — on a SECOND one the department exists and `dry_run` is the only protection. That is the realistic case (preview → import → preview again) and it now has its own test. **k BUILT 2026-08-07** (`routes/projects/filters.py`, `lib/grouping.ts`, `components/FilterBar.tsx`; 34 hermetic + 24 vitest cases, 13 mutants red, and 23 checks against a REAL Postgres) — closes §11.2's third gap, the one whose name was the sentence *"my open bugs in Ops, grouped by assignee"*. **ONE filter builder serves both the list endpoint and saved views**, because a saved view is nothing but a stored set of these filters and two implementations would drift — a *saved* view showing a different set than the same filters typed by hand is the one thing it may not do, so a test compares the two outputs directly. **Every filter is a WHERE clause**: paging happens in SQL, so a filter applied in Python after `LIMIT` returns short pages, and *"page 2 is empty but there are 40 more"* is a bug people work around for months instead of reporting. **`overdue` means past due AND still open** — a finished task with a past due date is done, and permanent red is how a board teaches people to ignore red. **An unknown category is a 422 naming the five real ones**, not an empty board a client reads as "this project is empty". Unknown config **keys** are DROPPED while a bad **value** falls back, because those are different failures: a view is a preference written by an older client, so refusing one over an unrecognised key would make every deploy a migration of everybody's saved views, whereas rendering still has to produce something. On the board, **a task with two assignees appears in BOTH columns** (it is both people's work; picking one hides it from the other, so the header counts tasks not group sizes), **empty status lanes are kept** while every other grouping drops empties (a missing "In progress" column reads as "no such state", not "nothing in progress"), and **dragging is offered only when the columns are statuses** — a drop writes the field the columns represent, and status is the one that is a plain PATCH, so a card that can be dragged into a column which cannot accept it and snaps back is worse than an honestly static column. `toConfig` is deliberately NOT `toQuery`: a query string carries only text so `toQuery` writes `"true"`, and `fromConfig` refuses a string where a toggle belongs, so a view built from query shape would come back with every toggle silently cleared. The project's **order-bearing board is withheld from the chips** — `tree.py` seeds one `board` view per project and it owns every `pm_view_task_positions` row, so offering its ✕ would offer to delete every hand-arranged position; saved views sit at position 300 above the seeded pair and `orderBearingView` is one function used by both the drag handler and the delete guard. **A FIFTH live bug, found the same way as the previous four:** `due_before` was `CAST(:due_before AS timestamptz)` with the raw query-string value — asyncpg infers the parameter's type FROM that cast and then refuses to encode a `str`, so the query never reached Postgres and **`?due_before=…` answered 500** while the hermetic fake, which agrees with whatever SQL it is handed, stayed green. `parse_when` parses on this side and binds a real `datetime`; garbage is a 422 that says what was expected; a naive value is read as UTC rather than inheriting the connection's TimeZone. Two tests — one on the bound value's TYPE, one refusing any `CAST(:param AS timestamp…)` anywhere in the builder — so the next `after=` filter written the obvious way fails in CI instead of in front of a member. **l BUILT 2026-08-07** (mig `155_projects_custom_fields.sql`, `routes/projects/custom_fields.py`, `lib/customFields.ts` + the panel block and the Fields dialog; 47 hermetic + 36 vitest cases, 23 mutants red, 35 checks against a REAL Postgres) — ClickUp's signature feature, and the shape §5's non-goals already recorded as the additive path: **definitions in a table, values denormalised onto the task as JSONB keyed by `field_key`**. NOT a row per (task, field): that is the textbook EAV answer and costs a join per field on every board paint — five fields across two hundred imported tasks is a thousand rows to gather and re-pivot, per render — whereas the JSONB column arrives with the task for free. **The cost is stated rather than discovered**: a value is not referentially tied to its definition, so the DATABASE cannot stop a key no definition owns from being written, and that guarantee moves into Python. Hence the validation IS the feature: an unknown key is a **422 not a silent drop** (a typo that no-ops looks exactly like a save); a patch **MERGES** (a client that knows three of five fields must not wipe the other two — and an older client, or an automation written before a field existed, is precisely that client); an explicit **null CLEARS the key** rather than storing a null, because it is the only way to express "unset this" and a stored null makes "never filled in" and "deliberately emptied" one value in every filter; and **`true` is not the number 1** — `isinstance(True, int)` is True in Python, so the coercers are one-per-type in a dispatch dict specifically so the boolean check can never drift below the number one. **The deliberate departure from Paca:** its research notes record "deleting a definition does not clean task data" as an accepted cost; it is NOT accepted here, because a key left in the JSONB is invisible — no definition means no column, no form row, no filter — until somebody recreates the name and every old value resurfaces carrying the new meaning. The cleared count is reported (R7/R8). Two things a definition may not change once values exist, both because the stored values would stop meaning what they say: **`field_key` is never editable** (it is the identity every value is filed under) and **`field_type` is a 409 naming the count** (text→select cannot re-interpret what is already written); dropping a select option some task holds is refused the same way, adding one is free, and the UI shows the derived key while the name is still being typed since that is the last moment anybody can change it. **Custom fields are REVERTIBLE, which is what makes them first-class:** `patch_task` folds a custom edit into the SAME `field_change` activity under `custom.` rather than inventing an activity type — `record_activity` refuses a type the CHECK does not list, the trap that made every attachment upload answer 422 — and revert restores by **merging onto what the task holds NOW**, never writing back the whole object, since another field may have been edited since and replacing the blob would silently undo that too. **A bug the ticket's own tests caught before it shipped:** `changedValues` compared a form's boolean against a `null` baseline, so a task with an unanswered checkbox sent `open:false` on EVERY save and posted a timeline entry for an edit nobody made — a checkbox has no "unset" state to render, so `false` is its baseline. **And a fence that was quietly a subset check:** `test_projects_routes` asserted a LIST of mounted paths, which catches the module somebody remembered to add a path for; it now also reads the package directory and asserts every module declaring a `@router` route is imported by `__init__.py` — the C1 trap where a missing import mounts nothing while every direct-call test still passes. Verified by deleting the import and watching it fail. **m BUILT 2026-08-07** (mig `156_projects_tags.sql`, `routes/projects/tags.py`, `lib/tags.ts` + the panel picker, the filter row, a `tag` board axis and the Tags dialog; 31 hermetic + 37 vitest cases, 16 mutants red, 30 checks against a REAL Postgres) — the row the research notes left open ON PURPOSE: `paca_pm_research_2026-08.md` row 13 REFUSED Paca's model (*"a bare jsonb string array on tasks. No registry, no colors, no rename/merge — the weakest part of Paca's model"*) and §5 shipped `pm_tasks.tags TEXT[]` in its place with a registry named as additive later. **The array STAYS** — a join table would add a row per tag per task and a join to every board paint, to buy referential integrity this app enforces in one place, whereas the array arrives with the task and its GIN index (146) already answers "tagged X". What the registry buys: **one spelling per tag** (identity is case-INSENSITIVE via a unique index over `lower(name)` so two racing requests cannot create both, display is case-PRESERVING, and the task's array stores the REGISTRY's form — which is what makes "filter by bug finds all of it" true rather than aspirational, and what lets a rename be one statement); **rename**; **merge**; and a **colour**. **Applying an unregistered tag REGISTERS it** — refusing would make tagging a two-step errand (leave the task, create the tag, come back), which is how tagging gets abandoned, and an abandoned tag set is worse than a messy one. **The cost is stated: every typo becomes a tag** — which is exactly why merge is here and is not optional, and why the picker SHOWS the moment of creation rather than minting silently. **A rename onto an existing name is a 409, not a silent merge**: different operations, different outcomes, and one of them destroys a tag — quietly doing the destructive one because the names collided is what stops people using a rename button. **A task carrying BOTH tags ends a merge with the target ONCE** — the case that is easy to get wrong, and getting it wrong leaves a duplicate that renders twice and survives the next merge too; `merged_tags` is pure so that case is asserted directly, and the rewrite runs over affected rows in Python rather than as an `array_replace`, which would leave the duplicate. **TWO tag filters** because both questions get asked and neither answers the other: `tags` is ANY (`&&`) and `tags_all` is ALL (`@>`); collapsing them would silently pick a meaning, and with three tags the answers differ by almost everything. On the board a task with three tags appears in three columns, the same honesty as two assignees appearing in both theirs. **The migration backfills AND rewrites data, deliberately and narrowly:** `tags` has been on `pm_tasks` since 146 and the import path writes them, so an empty registry beside a tagged corpus means the first rename finds nothing; the winning display form is **the spelling people actually use** (most frequent, ties broken deterministically — `min()` alone would canonicalise 400 "Bug" to a single stray "BUG"), and task arrays are then made to agree, only ever swapping one CASING of a tag for another, with the count reported in a NOTICE. **A bug the live run caught in that block:** the canonicalisation used the implicit-comma `FROM pm_tasks t, unnest(t.tags) ... LEFT JOIN` form, where the LEFT JOIN binds only to `unnest(...)` and `t` is not in scope for its ON clause — the migration aborted with "invalid reference to FROM-clause entry for table t". Rewritten as `CROSS JOIN LATERAL … WITH ORDINALITY`, which also fixed a second problem the first version would have shipped: `array_agg(DISTINCT ...)` sorts by its own expression, so every task's tag list would have come back alphabetised. **n BUILT 2026-08-07** (`routes/projects/bulk.py` → `POST /projects/tasks/bulk`, `lib/selection.ts`, `components/BulkBar.tsx` + checkboxes on board and list; 35 hermetic + 32 vitest cases, 16 mutants red, 34 checks against a REAL Postgres; **no migration**) — **the ticket §11.3 names as gating g, so that prerequisite is now satisfied.** It **reuses `automation.apply_task_patch` rather than growing a second writer**: that service exists because WS-27f needed an edit indistinguishable in validation from a human PATCH, and a bulk endpoint with its own field handling would be a third opinion about what a task edit is. Tags go through the same registry the panel uses, because the registry cannot be true if bulk is a second door into the array. **Status is named, never keyed — load-bearing here rather than stylistic**: a selection spans projects and a status id belongs to one root, so `status_id` for fifty tasks across three projects puts two thirds of them in a lane that is not theirs; it is a 422 with its OWN message, because it is the mistake somebody makes by copying a single-task PATCH body and "unknown field" would not explain why the thing that works on one task is refused on fifty. **Assignees and tags are ADD/REMOVE, never SET** — "assign these to Priya" means ALSO Priya, and a replace wipes every individual assignment the fifty tasks already carried; the destructive spelling is absent rather than discouraged. **Shape validated once, outcomes per task**, because those are different failures: an unsettable field is the same mistake for all fifty (422 before any write), while a status name present in one project and absent from another is a fact about THAT task — failing the batch for it makes a mixed selection unusable, which is precisely the selection somebody makes after an import. **An invisible task is SKIPPED, not an error** (R5: per-id reporting says exactly what a per-id 404 says, and aborting would let a caller probe for existence). **One transaction** — a re-triage that half happened is harder to recover from than one that did not, because nobody can tell which half. **Re-asserting a value is not a change** (`moved_people` is pure so the claim is asserted directly): fifty tasks already Priya's would each gain a timeline entry saying nothing, and a count nobody can trust is worse than no count. **ONE notification per person per batch, not one per task** — being handed fifty tasks should ring once and say fifty; fifty bells is a bell people turn off, which is WS-27j's own argument applied to the case that would have broken it. In the browser the selection is **pruned whenever the filter changes** (select forty, narrow to three, press Done must not act on thirty-seven nobody can see), a shift-click ranges over the board's ON-SCREEN order, a two-assignee card drawn in two columns counts once, and the outcome line names every category including the boring ones. **AND IT UNCOVERED A SHIPPED WS-27j BUG** (spec §11.12): `notifications.deliverable` probed only `project_clause('t.root_project_id')` while `core.task_visibility_clause` — whose docstring warns a second implementation "would drift the moment one is edited alone" — carries TWO ways in. So (1) anybody assigned work in a project they hold no grant on was judged undeliverable: they could OPEN the task, the assignment notified nobody, and the response told the assigner they could not see it — the silent assignment WS-27j exists to end, still open for the most common case in a grant-scoped app; and (2) scoping to `root_project_id` ALSO missed a grant made on a SUBPROJECT — the old test asserted that scoping on the argument that "probing `project_id` would miss a grant made on an ancestor", which is BACKWARDS, as a real-Postgres run showed: the closure is recursive and expands DOWNWARD. Fixed to use the shared clause; the test that encoded the wrong reasoning now records why it was wrong. **A fake collision the fix exposed:** `FakeDB` matched the rule-3 probe by substring, and the new clause embeds both `pm_task_assignees` and the closure's `UNION` — exactly the AUDIENCE branch's fingerprint — so `deliverable` got a list of assignees where it expected a visibility answer and four tests failed for a reason unrelated to the code under test. A fake that dispatches on substrings needs fingerprints that are SPECIFIC, not merely present. **o BUILT 2026-08-07** (mig `157_projects_recurrence.sql`, `routes/projects/recurrence.py`, `lib/recurrence.ts` + the repeat row in the task panel; 45 hermetic + 27 vitest cases, 31 mutants red, 39 checks against a REAL Postgres) — **NO SCHEDULER, and that is FORCED rather than chosen**: §5's non-goals say `/workflows` is the only engine (ADR-028/D6), so a recurrence worker here would be exactly the second engine the spec forbids. The successor is created **when a task CLOSES** — `apply_status_transition` already owns that moment, so a task finished from the board, from My work, from an automation or from a bulk edit all recur identically, and a second call site would be a fifth way to finish a task that forgets to. **The cost is stated:** a series only advances when somebody finishes the current one — a monthly report nobody closes does not pile up twelve copies (right), but a daily standup nobody ticks does not appear tomorrow (the honest limitation); materialising ahead is already reachable through the engine that owns scheduling (cron trigger + the `pm_task` node WS-27f added), so nothing needs undoing. **The anchor is PER RULE because the two answers mean different things**: `due` keeps the schedule ("stock count on the 1st" stays on the 1st however late the last was closed, so the series does not drift) and `completed` measures from when the work was actually done ("water the plants every 3 days" restarts when you water them). A `due` anchor also **catches up** — a monthly task closed six weeks late would otherwise produce a successor already overdue the moment it appeared — and the missed occurrences are SKIPPED rather than backfilled, because nobody wants four copies of a standup they did not attend. **The date arithmetic is where this is either right or quietly wrong for a year**, so it is pure and each case is one assertion: January 31st monthly (clamped at COMPUTATION time and stored as asked — storing the clamp permanently demotes the rule to the 28th after its first February), February 29th yearly, "every other Mon and Thu" (within a week it takes the next allowed day and only jumps `interval` weeks when the week runs out; a naive `+14 days` alternates between the two days instead of giving both days of every second week), and a 09:00 standup staying at 09:00. **Closing twice must not spawn twice** — a task can cross into `done` repeatedly (close, reopen to add a note, close again) and every crossing hits the same seam, so `recurrence_spawned_at` guards it and is NEVER cleared: reopening undoes `completed_at` but does not un-emit a successor that may already have been worked on. **Stopping a series keeps the work** (detach, not delete): they are real tasks, some finished, and a button that swept away three months of completed reports is one nobody presses twice. **TWO BUGS THE LIVE RUN CAUGHT AND READING COULD NOT:** (1) the weekly CHECK passed the very row it existed to reject — `array_length('{}', 1)` returns **NULL**, `NULL >= 1` is NULL, and a CHECK only FAILS on false, so a weekly rule with no weekdays inserted happily past a constraint that looked correct; `coalesce(…, 0)` fixes it and a test asserts the coalesce is present, since the hermetic suite has no database to try the expression on; and (2) `_next_number`/`_default_status` were reimplementations, one of which invented a column (`last_number`; the real one is `last_value`) — replaced by `core`'s own `next_task_number` and `load_default_status`, which is the same mistake WS-27n had just been careful to avoid, made two tickets later in the same package. **A third, caught by its own test:** `int(rule.get("interval") or 1)` turns an explicit `0` into "every 1" — a typo that looks exactly like a save, and one the DB CHECK would then have refused as a 500 rather than a 422. In the browser **the SENTENCE is the feature** — a form of five controls is a shape, whereas "Every 2 weeks on Mon, Thu, keeping to the schedule" is something somebody can check before committing, shown LIVE rather than on save because picking the wrong anchor is invisible until a cadence has drifted for three months; the occurrence limit reads as what is LEFT not the cap, and switching frequency clears the fields the new one does not use so a stale `day_of_month` cannot reappear. **p BUILT 2026-08-07** (`routes/projects/relations.py` → `GET /tasks/{id}/relations`, `lib/relations.ts` + the relations block in the panel; 21 hermetic + 16 vitest cases, 11 mutants red, 19 checks against a REAL Postgres; **no migration**) — closes *"data with no surface is a promise the product does not keep"*. **BOTH halves were genuinely unreachable, for different reasons:** links could be CREATED and DELETED since WS-27a but never LISTED (`get_task` returns a *count*), and subtasks could be created from the panel but never listed either (`?parent_task_id=` existed and nothing called it). What was missing was a way to read them and **one rule nobody had written down: `blocks` may not form a cycle.** `assert_no_task_cycle` has guarded `parent_task_id` since WS-27a and the identical hazard sat unguarded on links — A blocks B blocks C blocks A is a deadlock no human can resolve by finishing something, and every walk over it runs forever. The new guard is bounded by the same `MAX_DEPTH` and **tracks what it has seen**, because data can ALREADY contain a loop (every link created before the guard went in unchecked) and the walk must terminate over one rather than spin. **Only `blocks` is guarded** — a cycle in `relates_to` is redundant, not harmful, and refusing one would be a rule with no failure to prevent. **Blocked-ness is DERIVED and SHOWN, never ENFORCED**: refusing to close a blocked task is the obvious next step and is deliberately not taken, because dependencies in a real workspace are approximate and a tool that will not let somebody finish work they have finished is one they route around — after which the links stop being maintained and the feature is worse than absent. **Visibility is applied to the CHILDREN, not inherited from the parent**: a subtask can be moved into a project the reader cannot see, and listing it because its parent is readable would disclose a title from behind a grant (the live run asserts both the absence and that the title does not appear). ONE endpoint carries BOTH directions, because `blocks` outgoing means "this holds those up" and incoming means "this is waiting" — a client given one side would ask twice and still not know which was which; **Blocked by is shown FIRST** since it is the only section that changes what to do next, and empty sections are dropped because six empty headings on every task is how a panel becomes something people scroll past. Progress counts the status CATEGORY not `completed_at` (a project can name its finished lane anything, and `cancelled` is resolved), and reads as "1 of 3" rather than 33% | | **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | ✅ **a + b BUILT 2026-08-06 · b-write BUILT 2026-08-07** · 🟢 c–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE).. **a BUILT 2026-08-06** (mig `148_people_key_shape.sql` + `scripts/import_hr_people.py`; 22 cases, 11 mutants red, 1 equivalent): `UNIQUE(name)` dropped, partial unique on `lower(email)`, status CHECK. **P-1 did not name its own consequence** — the HR importer upserts `ON CONFLICT (name)` and would have failed outright, so a `source_key` (`:`) carries the upsert instead, backfilled BEFORE the constraint is dropped while `name` is still distinct. **Neither new constraint may block a deploy** (main was bitten twice this month): a duplicate address is quarantined into a new `email_conflict` column with a deterministic winner rather than failing `CREATE UNIQUE INDEX`, and the status CHECK is added `NOT VALID` then validated in a guarded block, so an unanticipated legacy value leaves a NOTICE instead of stopping the deploy. ⚠️ `schema.generated.sql` NOT refreshed — needs a live DB; regenerate on the first deploy that applies 148. **b BUILT 2026-08-06** (mig `149_people.sql`, `routes/people/`, `src/app/people/`; 32 hermetic + 28 vitest cases, 11 mutants red): `/people` directory, person page with all four panels, and the People Center's "Directory & org chart" sub-app flipped live — closing WS-13's outstanding read view. **Its own feature slug**, not `feature:tasks`: a manager who needs the org chart should not be handed the personal GTD task manager to get it. The gate is new but the HR **projection is imported** from `tasks.core` and a test asserts the function's *identity*, since two answers to "may this caller see skills" are two answers waiting to drift. Three filters (the `q` skills clause, `skill`, `has_capacity`) are dropped without `admin:members:read` so search cannot become an oracle for the hidden field — and the response carries `hr_visible` so the UI says "restricted" rather than leaving a blank strip to read as "nobody filled it in". Load is **computed from open assigned tasks** and carries `unestimated`, because a bar built from the estimate sum alone shows somebody holding thirty un-estimated tasks as completely free. The work panel is scoped by the **viewer's** grants and answers `available:false` without `feature:projects`. **Registration is FIVE places, not four** — the fifth is `test_org_access_enforcement.GATED_ROUTERS`, hand-maintained, where an absent router is unchecked rather than passing; also added the named `test_projects_is_registered_on_both_sides` that WS-27a never wrote. **b-write BUILT 2026-08-07** (`people/components/PersonEditor.tsx`, `people/lib/form.ts`, `people/lib/write.ts`; 26 hermetic + 23 vitest cases, 7 mutants red): closes the regression the 2026-08-06 scope narrowing opened — deleting the tasks app's People view took `PersonEditor` with it, and with it the only UI for creating a person, editing skills and uploading a résumé. The GET-only `/api/people` proxy is **unchanged**: the writes go to `/api/tasks/people`, where they have always lived. Controls are absent rather than disabled, driven by a new **`can_manage`** flag on the reads — hide-rather-than-disable is impossible unless the read tells the UI, and the alternative is drawing the button and letting the click find a 403. **Restoring it turned up three ways migration 148 had already broken the write routes**, each a 500 in front of an admin rather than a test failure: the status vocabulary moved (49's `inactive`/`on_leave` vs 148's CHECK) and is now ONE tuple shared by filter, facets, select and validation; `create_person` still refused a duplicate NAME, preserving precisely the behaviour 148 dropped `UNIQUE(name)` to remove; and nothing checked the address 148 made unique, so a duplicate was an `IntegrityError` instead of a 409 naming the other row. **The lesson recorded in spec §10:** a migration that changes a table's shape has to be walked against every route that WRITES it — the read routes were built after 148 and were correct by construction, the write routes predated it and were never revisited | From c52c34d45589437e52428a6d509314b7b8daf8dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 20:50:55 +0000 Subject: [PATCH 12/22] =?UTF-8?q?wip(WS-29a+b):=20pm=5F*=20tenant=20key=20?= =?UTF-8?q?and=20the=20tenant=20predicate=20=E2=80=94=20NOT=20YET=20DONE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ INTERMEDIATE COMMIT. The suite is green and the tree is coherent, but this ticket is NOT finished and must not be read as such. Committed because the working tree cannot be left dirty across turns, not because the work is complete. What is verified, by me, against a live Postgres 16 rather than taken on trust: * migration 158 applied — all 17 pm_* tables carry organization_id, all 17 NOT NULL, and every one REFERENCES organization(id), confirmed by reading pg_constraint (not by reading the migration). * core.py carries the tenant: Visibility.organization_id, a _TENANT_PROJECTS_SQL for the unrestricted (`data:org:read`) path, and `g.organization_id = CAST(:vis_org AS uuid)` inside the grant closure — which is the line that makes `subject = 'org'` mean "everybody IN THIS ORGANIZATION" rather than everybody. It is flagged in place as the most dangerous line in the retrofit, which it is. * the ratchet moved the 17 into EXPECTED_SCOPED and dropped BASELINE_UNSCOPED from 137 to 120, which is the ratchet working as designed rather than being edited around. * 661 tests pass, from 45 failed / 611 passed an hour ago. What is NOT done, and why this is `wip`: * 661 is EXACTLY the pre-tenancy count. No two-tenant isolation test has been added yet — and that is the single most important thing this ticket owes. A tenant boundary with no test proving a second tenant cannot see the first is a boundary nobody has checked. * no live-Postgres two-tenant run yet. Every ticket this session has had a bug that only the live run found; there is no reason to think this one is the exception, and this is the ticket where the bug would be a leak. * EXPECTED_SCOPED still contains crm_activities/crm_contacts/crm_deals, which the leak audit proved are NOT tenant-scoped — their organization_id references crm_organizations, a customer company. That is my defect (the ratchet matches column NAME, not FK target) and it is fixed next, once the agent releases the file. The agent is still running. I will not call this done until the isolation proof exists, the live run is clean, and the homonym bug is out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/routes/projects/attachments.py | 10 +- .../gateway/gateway/routes/projects/core.py | 190 ++++++++- .../gateway/routes/projects/import_clickup.py | 29 +- .../gateway/routes/projects/import_tasks.py | 35 +- .../gateway/routes/projects/personal.py | 17 + .../gateway/gateway/routes/projects/tree.py | 13 + infra/postgres/158_projects_tenancy.sql | 392 ++++++++++++++++++ tests/unit/_projects_fakes.py | 146 ++++++- tests/unit/test_projects_attachments.py | 44 +- tests/unit/test_projects_grants.py | 4 + tests/unit/test_projects_import_tasks.py | 23 +- tests/unit/test_projects_notifications.py | 12 +- tests/unit/test_tenancy_boundary.py | 30 +- 13 files changed, 896 insertions(+), 49 deletions(-) create mode 100644 infra/postgres/158_projects_tenancy.sql diff --git a/apps/services/gateway/gateway/routes/projects/attachments.py b/apps/services/gateway/gateway/routes/projects/attachments.py index 34474a03..e7eaa482 100644 --- a/apps/services/gateway/gateway/routes/projects/attachments.py +++ b/apps/services/gateway/gateway/routes/projects/attachments.py @@ -199,9 +199,13 @@ async def serve_attachment( vis = await resolve_visibility(db, user) params: dict[str, Any] = {"aid": attachment_id} clauses = ["ta.attachment_id = CAST(:aid AS uuid)"] - if not vis.unrestricted: - clauses.append(vis.project_clause("t.root_project_id")) - params.update(vis.params) + # ⚠️ Unconditional since WS-29b. The `if not vis.unrestricted` that + # guarded this was correct while the unrestricted clause was the literal + # `TRUE` — skipping a predicate that filters nothing costs nothing. It + # is now the TENANT, so skipping it served every organization's files to + # any `data:org:read` holder. + clauses.append(vis.project_clause("t.root_project_id")) + params.update(vis.params) row = (await db.execute( text( "SELECT a.name, a.mime, a.path " diff --git a/apps/services/gateway/gateway/routes/projects/core.py b/apps/services/gateway/gateway/routes/projects/core.py index f7f76867..95d355e5 100644 --- a/apps/services/gateway/gateway/routes/projects/core.py +++ b/apps/services/gateway/gateway/routes/projects/core.py @@ -110,6 +110,10 @@ #: granting it is registered as an owner gate. ORG_READ = "data:org:read" +#: What a caller whose email the directory does not know is told when they try +#: to CREATE something. Reads never say this — they simply see nothing (§D-MT-1). +NO_ORGANIZATION = "Your account is not attached to an organization." + # ── Models ────────────────────────────────────────────────────────────────── # @@ -414,21 +418,60 @@ def clean_payload(payload: BaseModel) -> dict[str, Any]: WHERE lower(au.email) = :email AND au.status = 'active' """ +#: The caller's TENANT (WS-29a/b, D-MT-1 (a)). One person belongs to exactly one +#: organization, so `X-User-Email` alone resolves it and no request carries a +#: tenant discriminator. `app_user.email` is globally UNIQUE, which is what makes +#: this a single-row answer rather than a choice the caller could influence. +#: +#: A person with no `app_user` row resolves to NULL, and every clause below then +#: matches nothing — see :attr:`Visibility.organization_id`. +_MY_ORGANIZATION_SQL = """ +SELECT au.organization_id AS organization_id +FROM app_user au +WHERE lower(au.email) = :email AND au.status = 'active' +""" + +#: Every project in the caller's organization, ignoring grants. This is what +#: ``data:org:read`` means AFTER WS-29b: unrestricted **within a tenant**. +_TENANT_PROJECTS_SQL = """ +SELECT id FROM pm_projects WHERE organization_id = CAST(:vis_org AS uuid) +""" + #: Projects the caller may see: those carrying a matching grant, plus everything #: beneath them. The recursion descends from the granted seeds rather than #: walking each project's ancestry upward — same answer, and it visits a subtree #: once instead of once per descendant. +#: +#: ⚠️ **`g.organization_id = :vis_org` IS THE SINGLE MOST DANGEROUS LINE IN THIS +#: PACKAGE** (multi_tenancy.md §6). `subject = 'org'` means "everybody", and +#: until a second organization exists that is correct. The moment one is +#: onboarded, an un-tenanted `subject = 'org'` grant hands every project in the +#: deployment to every caller in it. The predicate is on the GRANT row rather +#: than joined through `pm_projects` because D-MT-3 put the key on every table +#: precisely so this needs no join. +#: +#: ⚠️ The parentheses around the three subject arms are load-bearing. Without +#: them `AND` binds tighter than `OR` and the tenant filter would apply to the +#: `subject = 'org'` arm alone — leaving the email and group arms unscoped, +#: which is the same leak wearing a subtler hat. +#: +#: The recursive step repeats the tenant filter. The trigger in migration 158 +#: already makes a cross-tenant parent impossible, so this is defence in depth: +#: the closure must not be the thing that would leak if that trigger were ever +#: dropped. _VISIBLE_PROJECTS_SQL = """ WITH RECURSIVE granted AS ( SELECT DISTINCT g.project_id AS id FROM pm_project_grants g - WHERE g.subject = 'org' - OR lower(g.subject) = :vis_email - OR g.subject = ANY(:vis_groups) + WHERE g.organization_id = CAST(:vis_org AS uuid) + AND (g.subject = 'org' + OR lower(g.subject) = :vis_email + OR g.subject = ANY(:vis_groups)) UNION SELECT p.id FROM pm_projects p JOIN granted a ON p.parent_project_id = a.id + WHERE p.organization_id = CAST(:vis_org AS uuid) ) SELECT id FROM granted """ @@ -441,40 +484,88 @@ class Visibility: ``unrestricted`` is the ``data:org:read`` holder — the People Center's full-portfolio view. For everyone else, :attr:`clause` is a subquery over the grant closure and callers ``AND`` it into their own WHERE. + + ⚠️ **`unrestricted` means unrestricted WITHIN A TENANT, never across them.** + Before WS-29b both clause helpers answered the literal ``TRUE`` for this + caller, which was correct while the deployment had one organization and is a + whole-database leak the moment it has two. Every arm of every clause below + now carries the tenant, including this one. """ unrestricted: bool email: str groups: tuple[str, ...] + #: The caller's tenant, or ``None`` for somebody with no ``app_user`` row. + #: + #: ``None`` FAILS CLOSED and does so by construction rather than by a check: + #: every clause compares a column to ``CAST(:vis_org AS uuid)``, and SQL's + #: ``column = NULL`` is NULL, never true. A caller the directory does not + #: know sees nothing — which is the right answer for a mention recipient or + #: a service identity that was never onboarded, and the wrong answer to give + #: by accident, so it is stated here. + organization_id: str | None = None @property def params(self) -> dict[str, Any]: + # `vis_org` is bound even when unrestricted, because the unrestricted + # clause is no longer `TRUE` — it is the tenant. if self.unrestricted: - return {} - return {"vis_email": self.email, "vis_groups": list(self.groups)} + return {"vis_org": self.organization_id} + return { + "vis_email": self.email, + "vis_groups": list(self.groups), + "vis_org": self.organization_id, + } def project_clause(self, column: str = "id") -> str: """A predicate restricting ``column`` (a project id) to the visible set.""" if self.unrestricted: - return "TRUE" + return f"{column} IN ({_TENANT_PROJECTS_SQL})" return f"{column} IN ({_VISIBLE_PROJECTS_SQL})" +async def resolve_organization_id(db: Any, email: str) -> str | None: + """The tenant one email belongs to, or ``None`` if the directory has no row. + + One lookup per request, on the seam every app already reads. D-MT-1 (a) is + what makes it a lookup rather than a negotiation: the answer cannot depend on + anything the caller sends. + """ + clean = (email or "").strip().lower() + if not clean: + return None + row = (await db.execute( + text(_MY_ORGANIZATION_SQL), {"email": clean}, + )).fetchone() + organization_id = getattr(row, "organization_id", None) if row else None + return str(organization_id) if organization_id is not None else None + + async def resolve_visibility(db: Any, user: UserContext) -> Visibility: """Read the caller's authority once per request. ``data:org:read`` short-circuits the group lookup: an unrestricted caller's groups cannot change the answer, and asking anyway would put a join on every portfolio read. + + ⚠️ It no longer short-circuits the TENANT lookup, and the order here is the + whole point: the organization is resolved BEFORE the permission is consulted, + because ``data:org:read`` widens a caller inside their organization and must + not be able to widen them out of it. """ - if user is not None and user.has_permission(ORG_READ): - return Visibility(unrestricted=True, email="", groups=()) email = actor(user).lower() + organization_id = await resolve_organization_id(db, email) + if user is not None and user.has_permission(ORG_READ): + return Visibility( + unrestricted=True, email="", groups=(), + organization_id=organization_id, + ) rows = (await db.execute(text(_MY_GROUPS_SQL), {"email": email})).fetchall() return Visibility( unrestricted=False, email=email, groups=tuple(r.subject for r in rows if getattr(r, "subject", None)), + organization_id=organization_id, ) @@ -519,6 +610,11 @@ async def resolve_visibility_for(db: Any, email: str) -> Visibility: clean = (email or "").strip().lower() if not clean: return Visibility(unrestricted=False, email="", groups=()) + # Same order as `resolve_visibility`, for the same reason: the tenant is + # resolved before the permission, so `data:org:read` cannot widen a + # recipient out of their own organization. A directory-only colleague with + # no `app_user` row resolves to None and sees nothing. + organization_id = await resolve_organization_id(db, clean) rows = (await db.execute( text(_EFFECTIVE_PERMISSIONS_SQL), {"email": clean}, )).fetchall() @@ -531,7 +627,10 @@ async def resolve_visibility_for(db: Any, email: str) -> Visibility: # has_permission` delegates to. Same allow/deny precedence, same wildcard # matching, one implementation. if access.has(ORG_READ): - return Visibility(unrestricted=True, email="", groups=()) + return Visibility( + unrestricted=True, email="", groups=(), + organization_id=organization_id, + ) group_rows = (await db.execute( text(_MY_GROUPS_SQL), {"email": clean}, )).fetchall() @@ -540,9 +639,44 @@ async def resolve_visibility_for(db: Any, email: str) -> Visibility: email=clean, groups=tuple(r.subject for r in group_rows if getattr(r, "subject", None)), + organization_id=organization_id, ) +def require_organization(vis: Visibility) -> str: + """The caller's tenant, or 403 — for the writes that must DECIDE one. + + Only the creation of a ROOT ``pm_projects`` row reaches this. Everything + else beneath a project inherits the tenant from its parent in the database + (migration 158's ``pm_organization_from_parent`` trigger), which is what + keeps the tenant a single decision instead of a thing 43 INSERT sites each + have to remember — D-MT-2 (b)'s named failure mode, and the one this system + demonstrably has. + + 403 and not 404 (the R5 rule for *records*): this says nothing about what + exists. It is the caller's own account that is not set up, and a 404 here + would send somebody hunting for a project that was never created. + """ + if not vis.organization_id: + raise HTTPException(status_code=403, detail=NO_ORGANIZATION) + return vis.organization_id + + +async def require_organization_of(db: Any, email: str) -> str: + """:func:`require_organization` for a caller who has no ``Visibility``. + + The personal-project seam and both importers create root projects without + ever building one — they are helpers reached from a route that has already + authorized the caller, and growing them a ``Visibility`` parameter would + push the tenant decision back out to each of their call sites, which is the + opposite of the point. + """ + organization_id = await resolve_organization_id(db, email) + if not organization_id: + raise HTTPException(status_code=403, detail=NO_ORGANIZATION) + return organization_id + + async def load_visible_project( db: Any, vis: Visibility, project_id: str, ) -> Any: @@ -576,13 +710,7 @@ async def load_visible_task(db: Any, vis: Visibility, task_id: str) -> Any: row = (await db.execute( text( "SELECT t.* FROM pm_tasks t " - "WHERE t.id = CAST(:task_id AS uuid) AND (" - f" t.project_id IN ({_VISIBLE_PROJECTS_SQL})" - " OR EXISTS (SELECT 1 FROM pm_task_assignees a " - " WHERE a.task_id = t.id AND lower(a.assignee) = :vis_email)" - ")" - if not vis.unrestricted - else "SELECT t.* FROM pm_tasks t WHERE t.id = CAST(:task_id AS uuid)" + f"WHERE t.id = CAST(:task_id AS uuid) AND {task_visibility_clause(vis)}" ), {"task_id": task_id, **vis.params}, )).fetchone() @@ -596,15 +724,33 @@ def task_visibility_clause(vis: Visibility, alias: str = "t") -> str: Same two ways in as :func:`load_visible_task`, so a task cannot be listable and unreadable (or the reverse) — the two would drift the moment one is - edited alone. + edited alone. ``load_visible_task`` no longer writes its own copy of this + for exactly that reason: it had one, and one copy of a two-armed predicate + is how the arms stop matching. + + ⚠️ **The tenant is composed ABOVE the grant closure, never inside it** + (multi_tenancy.md §6: "with the tenant predicate composed above the grant + closure rather than tangled into it"). The outer ``AND`` is not redundant + with the closure's own tenant filter — it is what scopes the SECOND arm: + + ``pm_task_assignees.assignee`` is a bare email (D-PM-4) matched by string, + and nothing stops a member of organization B typing a member of A's address + into it. Without this outer AND that row would make A's member see B's task, + through the escape hatch rather than through a grant. That is a third leak, + beside the two §6 names, and it is only visible if you read the arms + separately. """ + tenant = f"{alias}.organization_id = CAST(:vis_org AS uuid)" if vis.unrestricted: - return "TRUE" + # `data:org:read` is the whole portfolio OF ONE ORGANIZATION. This + # answered the literal `TRUE` before WS-29b. + return tenant return ( - f"({alias}.project_id IN ({_VISIBLE_PROJECTS_SQL})" - f" OR EXISTS (SELECT 1 FROM pm_task_assignees a" - f" WHERE a.task_id = {alias}.id" - f" AND lower(a.assignee) = :vis_email))" + f"({tenant}" + f" AND ({alias}.project_id IN ({_VISIBLE_PROJECTS_SQL})" + f" OR EXISTS (SELECT 1 FROM pm_task_assignees a" + f" WHERE a.task_id = {alias}.id" + f" AND lower(a.assignee) = :vis_email)))" ) diff --git a/apps/services/gateway/gateway/routes/projects/import_clickup.py b/apps/services/gateway/gateway/routes/projects/import_clickup.py index fee1467a..883f66b7 100644 --- a/apps/services/gateway/gateway/routes/projects/import_clickup.py +++ b/apps/services/gateway/gateway/routes/projects/import_clickup.py @@ -41,6 +41,7 @@ class this app cannot make silently. The plan is a read; the import writes only actor, insert_row, record_activity, + require_organization_of, router, ) from gateway.routes.projects.mapping import ( @@ -302,6 +303,7 @@ async def import_clickup( detail=f"Unknown Center(s): {unknown}. One of: {list(centers)}.", ) + organization_id = await require_organization_of(db, actor(user).lower()) summary = _Summary() for fact in facts.values(): await _import_space( @@ -310,6 +312,7 @@ async def import_clickup( created_by=actor(user), summary=summary, dry_run=payload.dry_run, + organization_id=organization_id, ) if payload.dry_run: # Nothing is committed, and the caller is told so in the response @@ -360,17 +363,28 @@ def as_dict(self, facts: dict[str, _SpaceFacts]) -> dict[str, Any]: async def _upsert_project( db: Any, *, name: str, clickup_id: str, kind: str, parent_id: str | None, created_by: str, summary: _Summary, - dry_run: bool, + dry_run: bool, organization_id: str, ) -> str | None: """One ClickUp container → one ``pm_projects`` row, idempotently. Keyed on ``clickup_id``, so a re-import updates the name in place instead of creating a second project — the property that makes this re-runnable during coexistence (§7.1). + + ⚠️ The key is ``(clickup_id, organization_id)`` here, not ``clickup_id`` + alone (WS-29b). Without the tenant arm the second organization to import a + workspace would ADOPT the first one’s projects and then UPDATE their names + — a cross-tenant write that no read predicate sees. `clickup_id` is still + globally UNIQUE in the schema, so that organization’s import now fails on + the constraint instead; migration 158 §6 records why widening it is a + separate ticket. """ existing = (await db.execute( - text("SELECT id FROM pm_projects WHERE clickup_id = :cid"), - {"cid": clickup_id}, + text( + "SELECT id FROM pm_projects " + "WHERE clickup_id = :cid AND organization_id = CAST(:org AS uuid)" + ), + {"cid": clickup_id, "org": organization_id}, )).fetchone() if existing is not None: summary.projects_existing += 1 @@ -394,6 +408,10 @@ async def _upsert_project( "clickup_kind": kind, "source": "import", "created_by": created_by, + # A Space is a ROOT project, so the trigger has no parent to derive + # from. Folders and lists carry it too, and migration 158’s trigger then + # REFUSES the row if it disagrees with the parent it was grafted onto. + "organization_id": organization_id, }) return str(row.id) @@ -401,11 +419,13 @@ async def _upsert_project( async def _import_space( db: Any, provider: Any, workspace_id: str, fact: _SpaceFacts, *, center: str | None, created_by: str, summary: _Summary, dry_run: bool, + organization_id: str, ) -> None: """One Space → a root project, its statuses, its containers and its tasks.""" root_id = await _upsert_project( db, name=fact.name, clickup_id=fact.space_id, kind="space", parent_id=None, created_by=created_by, summary=summary, dry_run=dry_run, + organization_id=organization_id, ) # The grant IS the mapping: granting the root to `group:` is the whole @@ -439,6 +459,7 @@ async def _import_space( db, name=folder.get("name") or "Untitled folder", clickup_id=folder_id, kind="folder", parent_id=root_id, created_by=created_by, summary=summary, dry_run=dry_run, + organization_id=organization_id, ) list_parents: dict[str, str | None] = {} @@ -449,6 +470,7 @@ async def _import_space( db, name=entry.get("name") or "Untitled list", clickup_id=list_id, kind="list", parent_id=root_id, created_by=created_by, summary=summary, dry_run=dry_run, + organization_id=organization_id, ) for folder in fact.folders: parent = container_ids.get(str(folder.get("id") or "")) @@ -459,6 +481,7 @@ async def _import_space( db, name=entry.get("name") or "Untitled list", clickup_id=list_id, kind="list", parent_id=parent, created_by=created_by, summary=summary, dry_run=dry_run, + organization_id=organization_id, ) if dry_run or root_id is None: diff --git a/apps/services/gateway/gateway/routes/projects/import_tasks.py b/apps/services/gateway/gateway/routes/projects/import_tasks.py index 116b678c..e895754b 100644 --- a/apps/services/gateway/gateway/routes/projects/import_tasks.py +++ b/apps/services/gateway/gateway/routes/projects/import_tasks.py @@ -57,6 +57,7 @@ insert_row, next_task_number, record_activity, + require_organization_of, router, ) from pydantic import BaseModel @@ -216,19 +217,27 @@ def as_dict(self, *, department: str, dry_run: bool) -> dict[str, Any]: async def _root_department( db: Any, name: str, created_by: str, tally: _Tally, dry_run: bool, + *, organization_id: str, ) -> str | None: """Find or create the one department. Returns its id, or None on a dry run. Matched by NAME among import-sourced roots, so a second run lands in the same department instead of stacking a duplicate beside it. + + ⚠️ The match is scoped to the importer's OWN organization (WS-29b). Names + are free text and "Company" is the default here, so without the tenant + predicate the second organization to run this import would have found the + first one's department and poured its entire ClickUp mirror into it — a + cross-tenant WRITE, which no read-side predicate would have caught. """ row = (await db.execute( text( "SELECT id FROM pm_projects " - "WHERE parent_project_id IS NULL AND lower(name) = :name " + "WHERE organization_id = CAST(:org AS uuid) " + " AND parent_project_id IS NULL AND lower(name) = :name " "ORDER BY created_at LIMIT 1" ), - {"name": name.strip().lower()}, + {"name": name.strip().lower(), "org": organization_id}, )).fetchone() if row is not None: tally.projects_existing += 1 @@ -242,6 +251,10 @@ async def _root_department( created = await insert_row(db, "pm_projects", { "name": name.strip(), "created_by": created_by, "source": "import", "description": "Imported from the Tasks app's ClickUp mirror.", + # A ROOT project, so nothing upstream supplies the tenant and the + # trigger has no parent to derive it from. Every node beneath this one + # inherits it (migration 158). + "organization_id": organization_id, }) project_id = str(created.id) await _seed_root(db, project_id, created_by) @@ -366,8 +379,10 @@ async def import_from_tasks( db = await _get_db() try: + organization_id = await require_organization_of(db, who.lower()) root_id = await _root_department( db, department, who, tally, payload.dry_run, + organization_id=organization_id, ) account_clause = "" @@ -396,9 +411,21 @@ async def import_from_tasks( for row in lists: name = (getattr(row, "name", None) or "Untitled list").strip() clickup_id = str(row.provider_ref) + # ⚠️ Tenant-scoped (WS-29b), even though `clickup_id` is globally + # UNIQUE. Resolving another organization's project as "already + # present" would have mirrored this whole list into their tree. The + # global UNIQUE means the follow-on insert then FAILS for a second + # organization importing the same workspace — loudly, and that is + # the better of the two wrong answers until the constraint is + # widened to `(organization_id, clickup_id)`; migration 158 §6 + # records why that is not done here. existing = (await db.execute( - text("SELECT id FROM pm_projects WHERE clickup_id = :cid"), - {"cid": clickup_id}, + text( + "SELECT id FROM pm_projects " + "WHERE clickup_id = :cid " + " AND organization_id = CAST(:org AS uuid)" + ), + {"cid": clickup_id, "org": organization_id}, )).fetchone() if existing is not None: tally.projects_existing += 1 diff --git a/apps/services/gateway/gateway/routes/projects/personal.py b/apps/services/gateway/gateway/routes/projects/personal.py index fc0b6d34..c2ea3843 100644 --- a/apps/services/gateway/gateway/routes/projects/personal.py +++ b/apps/services/gateway/gateway/routes/projects/personal.py @@ -49,6 +49,7 @@ next_task_number, now, record_activity, + require_organization_of, resolve_visibility, router, row_to_dict, @@ -124,6 +125,14 @@ def derive_disposition( # ── The personal project ──────────────────────────────────────────────────── async def _load_personal_project(db: Any, email: str) -> Any | None: + """This member's personal project. + + Keyed on the email alone and NOT on the tenant, which is safe for exactly + one reason and it is worth naming: D-MT-1 (a) makes `app_user.email` + globally unique, so an email identifies one person in one organization. If + D-MT-1 is ever revisited this lookup is one of the places that has to grow a + tenant predicate — the project it returns is then used as a write target. + """ return (await db.execute( text( "SELECT * FROM pm_projects WHERE lower(personal_owner) = :who" @@ -147,6 +156,13 @@ async def ensure_personal_project(db: Any, email: str) -> Any: if existing is not None: return existing + # WS-29a. A personal project is a ROOT project, so nothing upstream can + # supply its tenant — this is the second (and last) place in the package + # that decides one. Resolved from the directory rather than taken from a + # `Visibility` because two of the three callers do not have one, and a + # signature change would push the decision back out to them. + organization_id = await require_organization_of(db, email) + project = await insert_row(db, "pm_projects", { "name": PERSONAL_PROJECT_NAME, "description": "Work only you can see. Tasks assigned to you from team " @@ -154,6 +170,7 @@ async def ensure_personal_project(db: Any, email: str) -> Any: "personal_owner": email, "created_by": email, "source": "manual", + "organization_id": organization_id, }) project_id = str(project.id) diff --git a/apps/services/gateway/gateway/routes/projects/tree.py b/apps/services/gateway/gateway/routes/projects/tree.py index 4727a1c7..b6aa3d36 100644 --- a/apps/services/gateway/gateway/routes/projects/tree.py +++ b/apps/services/gateway/gateway/routes/projects/tree.py @@ -42,6 +42,7 @@ insert_row, load_visible_project, record_activity, + require_organization, resolve_visibility, root_project_id, router, @@ -219,6 +220,18 @@ async def create_node( # and inherit that department's grants for it. await load_visible_project(db, vis, str(parent_id)) + # WS-29a. This is the ONE place in the package that decides a tenant: + # `pm_projects` is the root of every other `pm_*` row, and migration + # 158's trigger derives the key for all of them from here. Written for + # a child project too, not just a root — the trigger then REFUSES it if + # it disagrees with the parent's, which turns "the caller's org and the + # parent's org differ" into a refused write rather than a silent graft. + # + # AFTER the parent check, deliberately: a caller with no organization + # asking to create inside a project they cannot see must still get R5's + # 404. Answering 403 first would confirm the project exists. + values["organization_id"] = require_organization(vis) + row = await insert_row(db, "pm_projects", values) project_id = str(row.id) diff --git a/infra/postgres/158_projects_tenancy.sql b/infra/postgres/158_projects_tenancy.sql new file mode 100644 index 00000000..610e900a --- /dev/null +++ b/infra/postgres/158_projects_tenancy.sql @@ -0,0 +1,392 @@ +-- ============================================================================ +-- 158_projects_tenancy.sql — the tenant key on all 17 `pm_*` tables (WS-29a). +-- +-- Spec: ai-company-brain/specs/multi_tenancy.md §3 (D-MT-1, D-MT-3) and §5. +-- +-- WHY NOW, AND ONLY NOW. §2 is blunt about it: `POST /projects/import/clickup` +-- is the next thing WS-27 wants, and it writes hundreds of rows into these 17 +-- tables. Adding the column afterwards is a backfill and an ALTER on live rows; +-- adding it first is a one-line default on empty ones. "The cost of waiting is a +-- few days. The cost of not waiting is paid once per table, forever." +-- +-- D-MT-1 (ANSWERED, (a)): one person, one organization. `app_user.email` stays +-- globally UNIQUE, so a request's tenant is DERIVED from `X-User-Email` through +-- `app_user.organization_id`. Nothing here needs a tenant discriminator on the +-- wire, and no auth seam changes. +-- +-- D-MT-3: the key is carried on EVERY tenant-owned table, even where it is +-- derivable through a parent. Deriving it was rejected for three reasons that +-- are all true here — RLS policies cannot afford a join, a derived key cannot be +-- indexed, and "derivable" stops being true the moment a parent is nullable, +-- which `pm_tasks.parent_task_id` (ON DELETE SET NULL) already is. +-- +-- D-MT-2 IS STILL OPEN, so this migration adds NO row-level security. The column +-- is shaped so RLS can be layered on later without touching it again: one plain +-- `UUID NOT NULL` per row, never a lookup, which is exactly what +-- `organization_id = current_setting('app.org')::uuid` wants. +-- +-- ── The backfill, and what was actually in the tables ─────────────────────── +-- +-- §2 predicted these tables would be EMPTY (this deployment has never run the +-- ClickUp import). CHECKED against the live Postgres before choosing, and the +-- prediction was WRONG: 2 `pm_projects`, 1 `pm_project_grants`, 2 +-- `pm_task_statuses` and 10 `pm_tasks` rows were present — fixture residue from +-- WS-27's live verification runs, not a real import, but rows all the same and +-- `SET NOT NULL` does not care which. So every table is backfilled to the +-- `slug='default'` organization before the constraint lands. The UPDATE is a +-- no-op on an empty table and re-running it changes nothing, so it costs +-- nothing on a deployment where §2's prediction WAS right. +-- +-- If `organization` has no `slug='default'` row the backfill sets NULL and the +-- following `SET NOT NULL` fails the deploy LOUDLY. That is deliberate: a +-- silent guess at which organization owns somebody's work is worse than a +-- failed migration. +-- +-- Idempotent per infra/postgres/README.md — `ADD COLUMN IF NOT EXISTS`, +-- `CREATE INDEX IF NOT EXISTS`, `CREATE OR REPLACE FUNCTION/TRIGGER`, and a +-- backfill whose WHERE makes the second run match nothing. Pinned as TEXT by +-- tests/unit/test_projects_migration.py, which runs no database. +-- +-- Depends on: 130_org_access_control.sql (organization), 146_projects.sql, +-- 147, 150, 152, 155, 156, 157 (the other `pm_*` tables). +-- ============================================================================ + +BEGIN; + +-- ── 1. The column, on all 17 ──────────────────────────────────────────────── +-- +-- ON DELETE CASCADE matches how `app_user`, `org_group` and `org_role` already +-- reference `organization` (§6). Nullable for now; §3 backfills and §4 makes it +-- NOT NULL, because SET NOT NULL on a table with rows needs those rows filled +-- first and this deployment turned out to have some. + +ALTER TABLE pm_projects ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_project_grants ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_task_statuses ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_task_types ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_task_counters ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_tasks ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_task_assignees ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_task_links ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_activities ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_views ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_view_task_positions ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_task_personal ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_task_attachments ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_notifications ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_custom_fields ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_tags ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; +ALTER TABLE pm_recurrences ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization (id) ON DELETE CASCADE; + +-- ── 2. Keeping a child's tenant equal to its parent's ─────────────────────── +-- +-- D-MT-3 names this as the cost of carrying the key on every row: "the column +-- must be kept true on write, which is one more thing an INSERT can get wrong; +-- a CHECK against the parent's value is the cheap guard." +-- +-- ⚠️ A `CHECK` CANNOT DO THIS. A CHECK constraint may only read the row it is +-- on; comparing against another table's column is exactly what Postgres refuses +-- ("cannot use subquery in check constraint"). So the guard is a BEFORE trigger, +-- which is the only in-database mechanism that can see both rows. +-- +-- It does two jobs, and the first is the one that matters most: +-- +-- FILL. A child inserted with a NULL tenant inherits its parent's. This is +-- what makes the retrofit safe across 43 INSERT sites in 16 modules without +-- editing 43 call sites — and editing 43 call sites is precisely the +-- discipline D-MT-2 (b) says this system does not have ("correctness rests on +-- 143 tables' worth of query authors never forgetting, which is the discipline +-- that produced 137 unscoped tables in the first place"). The absence of code +-- is safe here, which is the property the system needs. +-- +-- REFUSE. A child inserted or updated with a tenant that DISAGREES with its +-- parent's is rejected, naming both. That is the case a fill-only default +-- would silently accept: a task moved into another organization's project, a +-- grant written against somebody else's project id. +-- +-- What it deliberately does NOT do: invent a tenant. A ROOT `pm_projects` row +-- has no parent, so nothing fills it, and `NOT NULL` refuses the insert. The +-- application must decide the tenant exactly once — at the root project — and +-- everything beneath it is derived. One decision point, checked; not 43. +-- +-- The parent lookup is a primary-key point read, and it is dynamic (`EXECUTE`) +-- so that ONE function serves all 19 trigger attachments. Nineteen bespoke +-- functions would plan marginally better and would be nineteen places for the +-- rule to drift. + +CREATE OR REPLACE FUNCTION pm_organization_from_parent() RETURNS trigger +LANGUAGE plpgsql AS $pm_org$ +DECLARE + parent_table CONSTANT TEXT := TG_ARGV[0]; + parent_column CONSTANT TEXT := TG_ARGV[1]; + parent_id UUID; + parent_org UUID; +BEGIN + -- `to_jsonb(NEW) ->> …` rather than a dynamic field reference, because + -- plpgsql has no syntax for "the column named by this variable" on a record. + parent_id := (to_jsonb(NEW) ->> parent_column)::uuid; + IF parent_id IS NULL THEN + -- A root project, or an activity attached to a project rather than a + -- task. Its OTHER trigger (or NOT NULL) decides. + RETURN NEW; + END IF; + + EXECUTE format('SELECT organization_id FROM %I WHERE id = $1', parent_table) + INTO parent_org USING parent_id; + + IF parent_org IS NULL THEN + -- The parent does not exist, or predates this migration. Say nothing + -- and let the foreign key (or NOT NULL) produce the real complaint — + -- a trigger that raised here would mask the actual error. + RETURN NEW; + END IF; + + IF NEW.organization_id IS NULL THEN + NEW.organization_id := parent_org; + ELSIF NEW.organization_id <> parent_org THEN + RAISE EXCEPTION + '%.organization_id (%) does not match %.organization_id (%)', + TG_TABLE_NAME, NEW.organization_id, parent_table, parent_org + USING ERRCODE = 'integrity_constraint_violation'; + END IF; + RETURN NEW; +END; +$pm_org$; + +-- `CREATE OR REPLACE TRIGGER` (Postgres 14+) is what makes this idempotent; +-- plain `CREATE TRIGGER` has no `IF NOT EXISTS` and would fail the second +-- deploy, which is the deploy nobody watches. +-- +-- Several tables carry TWO attachments. That is not redundancy: the second one +-- cross-checks a relationship the first cannot see. `pm_tasks` is the clearest +-- case — `project_id` fills the tenant and `root_project_id` then has to agree +-- with it, so a task whose root lives in another organization is refused rather +-- than stored. Triggers fire in name order and each one is fill-or-verify, so +-- the order between them does not change the answer. + +CREATE OR REPLACE TRIGGER trg_pm_projects_org_from_parent + BEFORE INSERT OR UPDATE ON pm_projects + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'parent_project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_project_grants_org_from_project + BEFORE INSERT OR UPDATE ON pm_project_grants + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_task_statuses_org_from_project + BEFORE INSERT OR UPDATE ON pm_task_statuses + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_task_types_org_from_project + BEFORE INSERT OR UPDATE ON pm_task_types + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_task_counters_org_from_project + BEFORE INSERT OR UPDATE ON pm_task_counters + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_tasks_org_from_project + BEFORE INSERT OR UPDATE ON pm_tasks + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +-- The cross-check described above: a task's denormalised root must live in the +-- same organization as the project it sits in. +CREATE OR REPLACE TRIGGER trg_pm_tasks_org_from_root + BEFORE INSERT OR UPDATE ON pm_tasks + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'root_project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_task_assignees_org_from_task + BEFORE INSERT OR UPDATE ON pm_task_assignees + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'task_id'); + +CREATE OR REPLACE TRIGGER trg_pm_task_links_org_from_source + BEFORE INSERT OR UPDATE ON pm_task_links + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'source_task_id'); + +-- ⚠️ A link is the one row that names two tasks, so it is the one row that +-- could STRADDLE two organizations. Verifying the target as well is what makes +-- a cross-tenant dependency edge impossible. +CREATE OR REPLACE TRIGGER trg_pm_task_links_org_from_target + BEFORE INSERT OR UPDATE ON pm_task_links + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'target_task_id'); + +-- `pm_activities` may hang off either a task or a project (its CHECK requires +-- at least one). Both attachments are declared; whichever column is populated +-- fills, and when both are, they must agree. +CREATE OR REPLACE TRIGGER trg_pm_activities_org_from_project + BEFORE INSERT OR UPDATE ON pm_activities + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_activities_org_from_task + BEFORE INSERT OR UPDATE ON pm_activities + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'task_id'); + +CREATE OR REPLACE TRIGGER trg_pm_views_org_from_project + BEFORE INSERT OR UPDATE ON pm_views + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_view_task_positions_org_from_view + BEFORE INSERT OR UPDATE ON pm_view_task_positions + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_views', 'view_id'); + +-- Same reason as the link's two ends: a position row names a view and a task, +-- and both have to be the same tenant's. +CREATE OR REPLACE TRIGGER trg_pm_view_task_positions_org_from_task + BEFORE INSERT OR UPDATE ON pm_view_task_positions + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'task_id'); + +CREATE OR REPLACE TRIGGER trg_pm_task_personal_org_from_task + BEFORE INSERT OR UPDATE ON pm_task_personal + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'task_id'); + +CREATE OR REPLACE TRIGGER trg_pm_task_attachments_org_from_task + BEFORE INSERT OR UPDATE ON pm_task_attachments + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'task_id'); + +CREATE OR REPLACE TRIGGER trg_pm_notifications_org_from_task + BEFORE INSERT OR UPDATE ON pm_notifications + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'task_id'); + +CREATE OR REPLACE TRIGGER trg_pm_custom_fields_org_from_project + BEFORE INSERT OR UPDATE ON pm_custom_fields + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_tags_org_from_project + BEFORE INSERT OR UPDATE ON pm_tags + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +CREATE OR REPLACE TRIGGER trg_pm_recurrences_org_from_project + BEFORE INSERT OR UPDATE ON pm_recurrences + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_projects', 'project_id'); + +-- ── 3. Backfill ──────────────────────────────────────────────────────────── +-- +-- See the header: these tables were NOT empty. Everything already here belongs +-- to the one organization this deployment has ever had. +-- +-- `pm_projects` first and on its own, because the triggers above then carry the +-- value down: a `pm_tasks` UPDATE re-reads its project. The per-table UPDATEs +-- that follow are therefore mostly belt-and-braces — they are what catches a +-- row whose parent was deleted between the two statements, and what makes each +-- table's fill independent of trigger firing order. + +UPDATE pm_projects SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE pm_project_grants SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_task_statuses SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_task_types SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_task_counters SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_tasks SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_task_assignees SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_task_links SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_activities SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_views SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_view_task_positions SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_task_personal SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_task_attachments SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_notifications SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_custom_fields SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_tags SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; +UPDATE pm_recurrences SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; + +-- ── 4. NOT NULL ──────────────────────────────────────────────────────────── +-- +-- The constraint that makes the tenant key a fact rather than a convention. It +-- is what turns "the application forgot" into a refused write instead of a row +-- that belongs to nobody and is therefore visible to nobody — or, worse, to +-- everybody, depending on how the predicate is written. +-- +-- SET NOT NULL on a column that is already NOT NULL is a no-op, so this replays. + +ALTER TABLE pm_projects ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_project_grants ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_statuses ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_types ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_counters ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_tasks ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_assignees ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_links ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_activities ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_views ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_view_task_positions ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_personal ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_attachments ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_notifications ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_custom_fields ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_tags ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_recurrences ALTER COLUMN organization_id SET NOT NULL; + +-- ── 5. Indexes — three, not seventeen ────────────────────────────────────── +-- +-- An index earns its place from a query that filters on it. WS-29b's predicate +-- filters on exactly three of these tables, and every other `pm_*` read reaches +-- its rows through `project_id`/`task_id`, which are already indexed and are far +-- more selective than a tenant key ever is. +-- +-- Seventeen single-column indexes on a column with ONE distinct value in this +-- deployment would be seventeen indexes the planner never picks and every write +-- has to maintain. When WS-29c adds RLS policies to the rest, the index each +-- policy needs should be added with that policy, sized to the plan it actually +-- produces. +-- +-- Both composites lead with `organization_id` because the tenant predicate is +-- the one clause EVERY query carries — a leading tenant column also serves the +-- bare `organization_id = …` lookup, so one index does both jobs. + +-- ⚠️ The hottest of the three. `_VISIBLE_PROJECTS_SQL`'s seed step is +-- `WHERE organization_id = :vis_org AND (subject = 'org' OR …)`, run once per +-- request on the read path of the entire app. Supersedes nothing: migration +-- 146's `idx_pm_project_grants_subject` still serves a subject-only lookup. +CREATE INDEX IF NOT EXISTS idx_pm_project_grants_org_subject + ON pm_project_grants (organization_id, subject); + +-- The closure's recursive step (`p.parent_project_id = a.id AND +-- p.organization_id = :vis_org`) and the unrestricted `data:org:read` clause +-- (`SELECT id FROM pm_projects WHERE organization_id = :vis_org`). +CREATE INDEX IF NOT EXISTS idx_pm_projects_org_parent + ON pm_projects (organization_id, parent_project_id); + +-- `task_visibility_clause`'s outer AND, on every task list, board, search and +-- calendar read. +CREATE INDEX IF NOT EXISTS idx_pm_tasks_org_project + ON pm_tasks (organization_id, project_id); + +-- ── 6. What this migration deliberately does NOT do ──────────────────────── +-- +-- * NO ROW-LEVEL SECURITY. D-MT-2 is open (§3). Adding policies now would +-- settle by default a decision the spec says is unsettled, and would need +-- every connection — ingestion workers, the broker, the migration runner — +-- to set a GUC that nothing sets today. +-- * `pm_projects.clickup_id` and `pm_tasks.clickup_id` stay GLOBALLY UNIQUE. +-- Under multi-tenancy that means two organizations cannot import the same +-- ClickUp workspace. Widening them to `UNIQUE (organization_id, clickup_id)` +-- is the right end state, but it is a change to the importer's conflict +-- handling as well as to the constraint, and it belongs with the ticket that +-- onboards the second tenant rather than smuggled in here. +-- * `pm_task_assignees.assignee` and `pm_project_grants.subject` stay bare +-- strings (D-PM-4). D-MT-1 (a) is what keeps that safe: one email, one +-- person, one organization. If D-MT-1 is ever revisited, these two columns +-- are where it lands first. + +COMMIT; diff --git a/tests/unit/_projects_fakes.py b/tests/unit/_projects_fakes.py index 4f5d85a3..168aa654 100644 --- a/tests/unit/_projects_fakes.py +++ b/tests/unit/_projects_fakes.py @@ -98,6 +98,44 @@ r"(?:\w+\.)?(\w+)\s+IN\s*\(\s*WITH\s+RECURSIVE\s+(\w+)", re.I ) +# ── The tenant predicate (WS-29b) ─────────────────────────────────────────── +# +# Three shapes, and they must be told apart because all three say +# `organization_id`. Reading any of them as another is how a mirror agrees with +# a route that scoped the wrong table. + +#: ``core._TENANT_PROJECTS_SQL`` — the unrestricted (`data:org:read`) clause, +#: which is the whole portfolio OF ONE ORGANIZATION. +_TENANT_PROJECTS = re.compile( + r"SELECT id FROM pm_projects WHERE organization_id\s*=\s*CAST\(:vis_org", re.I +) +#: The column that subquery restricts: ``t.root_project_id IN ( SELECT id FROM…`` +_IN_TENANT_SUBQUERY = re.compile( + r"(?:\w+\.)?(\w+)\s+IN\s*\(\s*SELECT id FROM pm_projects WHERE organization_id", + re.I, +) +#: The grant closure's own tenant arm — ⚠️ the line that makes `subject = 'org'` +#: mean "everybody **in this organization**". +_CLOSURE_IS_TENANTED = re.compile( + r"g\.organization_id\s*=\s*CAST\(:vis_org", re.I +) +#: ``.organization_id = CAST(:vis_org AS uuid)`` — the tenant composed +#: ABOVE the grant closure, and the whole of the unrestricted task clause. +_ROW_TENANT = re.compile( + r"\w+\.organization_id\s*=\s*CAST\(:vis_org\s+AS\s+uuid\)", re.I +) +#: The closure body, removed before looking for ``_ROW_TENANT`` — it contains +#: `g.organization_id`, and mistaking that for the outer AND would filter the +#: statement's own table by a predicate that is about the grant rows. +_CLOSURE_BODY = re.compile( + r"WITH RECURSIVE granted AS.*?SELECT id FROM granted", re.I | re.S +) + +#: The one organization this fake models. `organization` has exactly one seeded +#: row in the real schema (`slug='default'`), and every test that is not ABOUT +#: tenancy is written against that deployment. +DEFAULT_ORGANIZATION = "00000000-0000-4000-8000-0000000000aa" + _SUBQUERY_RE = re.compile(r"\b(SELECT|WITH)\b", re.I) @@ -293,6 +331,11 @@ class FakeProjectsDB: """An in-memory ``pm_*`` schema that answers the package's statements.""" def __init__(self) -> None: + #: The tenant every seeded `pm_*` row belongs to, and the answer this + #: fake gives when a route asks the directory which organization the + #: caller is in. Set it to ``None`` to model somebody with no + #: ``app_user`` row; seed real ``app_user`` rows to model two tenants. + self.organization_id: str | None = DEFAULT_ORGANIZATION self.tables: dict[str, list[dict[str, Any]]] = {} self.statements: list[str] = [] #: ``(statement, params)`` in order — how a test proves a write happened @@ -306,6 +349,13 @@ def seed(self, table: str, **columns: Any) -> SimpleNamespace: row = { "id": columns.pop("id", str(uuid4())), **_DEFAULTS.get(table, {}), + # WS-29a — every `pm_*` table carries the tenant key (D-MT-3), so a + # seeded row that lacked one would be invisible to every scoped read + # and would make the whole suite red for the wrong reason. An + # explicit `organization_id=` still wins: that is how the two-tenant + # tests put rows in the OTHER organization. + **({"organization_id": self.organization_id} + if table.startswith("pm_") else {}), **columns, } if table in _TIMESTAMPED: @@ -393,6 +443,24 @@ async def execute(self, sql: Any, params: dict | None = None) -> _Result: args = dict(params or {}) self.statements.append(statement) self.calls.append((statement, args)) + # WS-29b's tenant lookup: `X-User-Email` → `app_user.organization_id`. + # Answered from seeded `app_user` rows when a test has them — the + # two-tenant tests do — and otherwise from this fake's single + # organization, which is the deployment every other test is written + # against. `self.organization_id = None` models a caller the directory + # does not know, who then sees nothing because `column = NULL` is NULL. + if "au.organization_id AS organization_id" in statement: + who = str(args.get("email") or "").lower() + for row in self.rows("app_user"): + if str(row.get("email") or "").lower() == who: + return _Result([SimpleNamespace( + organization_id=row.get("organization_id"), + )]) + if self.rows("app_user") or self.organization_id is None: + return _Result([]) + return _Result([SimpleNamespace( + organization_id=self.organization_id, + )]) # WS-27k's assignee roll-up: one aggregate for a whole page of tasks, # rather than a query per card. Taught to the fake explicitly because # `GROUP BY` + `array_agg` is not a shape the generic WHERE reader can @@ -504,11 +572,25 @@ def _search_hits(self, statement: str, args: dict) -> list[Any]: scoped = "pm_project_grants" in statement visible = self.visible_project_ids( str(args.get("vis_email") or ""), list(args.get("vis_groups") or []), + organization_id=( + str(args.get("vis_org")) + if _CLOSURE_IS_TENANTED.search(statement) else None + ), ) skips_archived = "t.archived_at IS NULL" in statement + # Same rule as everywhere else: the tenant is applied only when the + # statement carries it. Search reaches every task in the app, so this is + # the read where losing it costs the most. + tenanted = bool(_ROW_TENANT.search(_CLOSURE_BODY.sub("", statement))) + org = str(args.get("vis_org")) + tenant_only = bool(_TENANT_PROJECTS.search(statement)) found: list[Any] = [] for task in self.rows("pm_tasks"): + if tenanted and str(task.get("organization_id")) != org: + continue + if tenant_only and str(task.get("project_id")) not in self.tenant_project_ids(org): + continue if scoped and str(task.get("project_id")) not in visible: continue if skips_archived and task.get("archived_at") is not None: @@ -663,6 +745,14 @@ def _insert(self, statement: str, table: str, args: dict) -> _Result: return _Result([SimpleNamespace(**row)]) row = {"id": str(uuid4()), **_DEFAULTS.get(table, {}), **values} + # Migration 158's `pm_organization_from_parent` trigger, mirrored: a + # child row inserted without a tenant INHERITS one rather than being + # refused, which is what lets 43 INSERT sites stay unedited. The fake + # models a single-tenant deployment, so it inherits *the* organization + # instead of walking to a parent — the derivation itself is proved + # against a real Postgres, not here. + if table.startswith("pm_") and row.get("organization_id") is None: + row["organization_id"] = self.organization_id if table in _TIMESTAMPED: row.setdefault("created_at", _now()) row.setdefault("updated_at", _now()) @@ -741,17 +831,30 @@ def _select(self, statement: str, table: str, args: dict) -> _Result: return _Result([SimpleNamespace(**r) for r in matched]) # visibility --------------------------------------------------------- - def visible_project_ids(self, email: str, groups: list[str]) -> set[str]: + def visible_project_ids( + self, email: str, groups: list[str], + organization_id: str | None = None, + ) -> set[str]: """The grant closure: directly granted projects, plus their descendants. Deliberately computed the same way the SQL does — seeds, then descend — rather than by walking each project's ancestry, so a subtree granted without its parent resolves identically in both. + + ⚠️ ``organization_id=None`` means the STATEMENT carried no tenant arm, + not "any tenant is fine". The caller reads that off the SQL, so a route + (or the closure itself) that loses its tenant filter stops being scoped + here too and the cross-tenant test goes red. Defaulting it to the fake's + own organization would have made the leak invisible. """ wanted = {str(g).lower() for g in groups} seeds = { str(g.get("project_id")) for g in self.rows("pm_project_grants") if ( + organization_id is None + or str(g.get("organization_id")) == organization_id + ) + and ( g.get("subject") == "org" or str(g.get("subject") or "").lower() == (email or "").lower() or str(g.get("subject") or "").lower() in wanted @@ -763,11 +866,22 @@ def visible_project_ids(self, email: str, groups: list[str]) -> set[str]: changed = False for project in self.rows("pm_projects"): parent = project.get("parent_project_id") + if organization_id is not None and ( + str(project.get("organization_id")) != organization_id + ): + continue if parent is not None and str(parent) in out and str(project["id"]) not in out: out.add(str(project["id"])) changed = True return out + def tenant_project_ids(self, organization_id: str | None) -> set[str]: + """Every project in one organization — the `data:org:read` answer.""" + return { + str(p["id"]) for p in self.rows("pm_projects") + if str(p.get("organization_id")) == str(organization_id) + } + def _subtree_ids(self, root_id: str) -> set[str]: out = {str(root_id)} changed = True @@ -887,13 +1001,43 @@ def _apply_subqueries( ) -> tuple[list[dict], bool]: seen = False + # ⚠️ The tenant, composed ABOVE the grant closure (WS-29b). Read from + # the statement with the closure's own body removed first, because that + # body ALSO says `organization_id` and the two predicates are about + # different tables. + # + # This is what scopes `load_visible_task`'s assignee escape hatch and + # the whole of the unrestricted (`data:org:read`) task clause. Delete + # either from the route and this stops applying, which is the point. + if _ROW_TENANT.search(_CLOSURE_BODY.sub("", where)): + seen = True + org = str(args.get("vis_org")) + rows = [r for r in rows if str(r.get("organization_id")) == org] + + # `data:org:read` — every project in ONE organization, grants ignored. + if any(_TENANT_PROJECTS.search(b) for b in blocks): + seen = True + column_match = _IN_TENANT_SUBQUERY.search(where) + column = column_match.group(1) if column_match else "id" + tenant = self.tenant_project_ids(args.get("vis_org")) + rows = [r for r in rows if str(r.get(column)) in tenant] + # The grant closure — applied ONLY when the statement actually carries # the subquery. A route that loses its visibility clause therefore stops # being filtered here, and its 404 test fails. if any("pm_project_grants" in b for b in blocks): seen = True + # ⚠️ And whether the CLOSURE is tenant-scoped is read off the + # closure's own text, not assumed. `subject = 'org'` means + # "everybody"; only `g.organization_id = :vis_org` makes it mean + # "everybody in this organization". Assuming it were always there + # is exactly how the leak §6 names would pass a green suite. visible = self.visible_project_ids( str(args.get("vis_email") or ""), list(args.get("vis_groups") or []), + organization_id=( + str(args.get("vis_org")) + if _CLOSURE_IS_TENANTED.search(where) else None + ), ) column_match = _IN_SUBQUERY.search(where) column = column_match.group(1) if column_match else "id" diff --git a/tests/unit/test_projects_attachments.py b/tests/unit/test_projects_attachments.py index bc62430d..eca937f1 100644 --- a/tests/unit/test_projects_attachments.py +++ b/tests/unit/test_projects_attachments.py @@ -79,6 +79,18 @@ async def close(self) -> None: def sql_touching(self, needle: str) -> list[str]: return [s for s in self.statements if needle in s] + def params_touching(self, needle: str) -> list[dict]: + """The bound values beside :meth:`sql_touching`'s statements. + + A clause naming `:vis_org` proves the SQL asks for a tenant; only the + parameter proves it asks for the CALLER's. + """ + return [ + args for statement, args in zip(self.statements, self.params, + strict=True) + if needle in statement + ] + class UploadStub: def __init__(self, filename: str, content: bytes, content_type: str = "image/png"): @@ -168,18 +180,36 @@ def test_serving_asks_whether_the_caller_can_see_a_task_it_hangs_off(db): assert "user_id" not in sql -def test_an_unrestricted_viewer_gets_no_scoping_predicate(db, monkeypatch): +def test_an_unrestricted_viewer_is_scoped_to_their_own_organization(db, monkeypatch): + """⚠️ WS-29b. This test used to assert the opposite — that a + ``data:org:read`` holder got NO predicate at all — and that was correct + while the deployment had one organization. + + What broke if it stayed that way: the route skipped the clause entirely for + an unrestricted caller, so the first `data:org:read` holder to exist + alongside a second tenant could fetch any organization's uploaded file by + guessing an attachment id. The grant closure is gone for this caller, by + design; the TENANT never is. + """ async def _resolve(_db, _user): from gateway.routes.projects.core import Visibility - return Visibility(unrestricted=True, email="", groups=()) + return Visibility( + unrestricted=True, email="", groups=(), organization_id="org-a", + ) monkeypatch.setattr(pm_attachments, "resolve_visibility", _resolve) db.serve_row = None with pytest.raises(HTTPException): run(pm_attachments.serve_attachment("a1", "x.png", user=user())) sql = db.sql_touching("FROM pm_task_attachments ta JOIN pm_tasks t")[-1] - assert "root_project_id IN" not in sql + # Still no GRANT closure — that is what `unrestricted` buys. + assert "pm_project_grants" not in sql + # But the tenant is there, and it is bound. + assert "t.root_project_id IN" in sql + assert "organization_id = CAST(:vis_org AS uuid)" in sql + params = db.params_touching("FROM pm_task_attachments ta JOIN pm_tasks t")[-1] + assert params["vis_org"] == "org-a" def test_an_attachment_on_no_visible_task_is_a_404_not_a_403(db): @@ -370,7 +400,13 @@ def test_the_upload_rules_are_imported_not_reimplemented(): def sql() -> str: hits = [ p for p in (REPO / "infra" / "postgres").glob("*.sql") - if "pm_task_attachments" in p.read_text(encoding="utf-8") + # By the CREATE, not by a mention: migration 158 (the tenant key) names + # every `pm_*` table, and a fixture that matched on the name alone would + # start finding two files and fail for a reason that is not about + # attachments at all. + if "CREATE TABLE IF NOT EXISTS pm_task_attachments" in p.read_text( + encoding="utf-8", + ) ] assert len(hits) == 1, hits raw = hits[0].read_text(encoding="utf-8") diff --git a/tests/unit/test_projects_grants.py b/tests/unit/test_projects_grants.py index da329186..c0a48048 100644 --- a/tests/unit/test_projects_grants.py +++ b/tests/unit/test_projects_grants.py @@ -68,6 +68,10 @@ async def _resolve(db, user): return vis return pm_core.Visibility( unrestricted=False, email=vis.email, groups=tuple(groups), + # Carried through, not re-derived. Dropping it here would hand every + # test in this file a tenant-less caller who can see nothing, which + # looks exactly like the scoping working. + organization_id=vis.organization_id, ) for module in MODULES: diff --git a/tests/unit/test_projects_import_tasks.py b/tests/unit/test_projects_import_tasks.py index 7e48ea0d..7770ee4d 100644 --- a/tests/unit/test_projects_import_tasks.py +++ b/tests/unit/test_projects_import_tasks.py @@ -50,6 +50,9 @@ def run(coro): return asyncio.run(coro) +#: The one organization this deployment has (`organization.slug = 'default'`). +ORGANIZATION = "00000000-0000-4000-8000-0000000000aa" + ADMIN = UserContext( email="owner@fracktal.in", role=UserRole.EXECUTIVE, access=build_access(["*"]), @@ -188,9 +191,27 @@ async def execute(self, sql: Any, params: dict | None = None) -> _Result: return _Result([SimpleNamespace( id=f"new-{self._new_id}", last_value=self._new_id, )]) - if "FROM pm_projects WHERE parent_project_id IS NULL" in statement: + # WS-29b's tenant lookup. The importer creates a ROOT project, which is + # the one row nothing upstream can supply an organization for. + if "au.organization_id AS organization_id" in statement: + return _Result([SimpleNamespace(organization_id=ORGANIZATION)]) + # ⚠️ Both of the importer's "is it already there?" lookups are answered + # ONLY when the statement carries the tenant, and only for the tenant it + # asks about. Answering them unconditionally is what would let a route + # that dropped its tenant arm keep passing — and dropping it here is a + # cross-tenant WRITE: the second organization to import pours its whole + # workspace into the first one's department. + if "parent_project_id IS NULL AND lower(name) = :name" in statement: + if "organization_id = CAST(:org AS uuid)" not in statement: + return _Result([]) + if args.get("org") != ORGANIZATION: + return _Result([]) return _Result([self.root] if self.root else []) if "SELECT id FROM pm_projects WHERE clickup_id" in statement: + if "organization_id = CAST(:org AS uuid)" not in statement: + return _Result([]) + if args.get("org") != ORGANIZATION: + return _Result([]) cid = args.get("cid") return _Result( [SimpleNamespace(id=f"existing-{cid}")] diff --git a/tests/unit/test_projects_notifications.py b/tests/unit/test_projects_notifications.py index 64a0ecde..c376171c 100644 --- a/tests/unit/test_projects_notifications.py +++ b/tests/unit/test_projects_notifications.py @@ -322,11 +322,19 @@ def test_the_visibility_probe_uses_the_SAME_predicate_the_read_path_uses(monkeyp def test_an_org_read_holder_is_unrestricted_even_by_wildcard(): """The owner holds `*`. Re-deriving the match in SQL is how two answers to - "may they see this" start disagreeing, so the REAL matcher decides.""" + "may they see this" start disagreeing, so the REAL matcher decides. + + ⚠️ Unrestricted stops at the tenant (WS-29b). The clause used to be the + literal ``TRUE``; a notification's "can they open the task it names" check + would then have said yes about another organization's task. + """ db = FakeDB(permissions={"owner@fracktal.in": [("*", "allow", True)]}) vis = run(pm_core.resolve_visibility_for(db, "owner@fracktal.in")) assert vis.unrestricted is True - assert vis.project_clause("t.root_project_id") == "TRUE" + clause = vis.project_clause("t.root_project_id") + assert clause != "TRUE" + assert "pm_project_grants" not in clause + assert "organization_id = CAST(:vis_org AS uuid)" in clause def test_a_deny_override_beats_the_role_grant(): diff --git a/tests/unit/test_tenancy_boundary.py b/tests/unit/test_tenancy_boundary.py index 1909e0a3..4105249e 100644 --- a/tests/unit/test_tenancy_boundary.py +++ b/tests/unit/test_tenancy_boundary.py @@ -1,9 +1,16 @@ """The tenant boundary, as a ratchet (WS-29). -⚠️ **CommandCenter is becoming multi-tenant, and today 137 of its 143 tables +⚠️ **CommandCenter is becoming multi-tenant, and today 120 of its 143 tables carry no tenant key.** That is not a bug list — it is the honest state of a system built for one organisation. The bug would be adding the 144th. +**137 → 120 on 2026-08-08 (WS-29a).** Migration 158 gave all 17 `pm_*` tables +`organization_id`, while they were still empty enough to make it a one-line +default rather than a backfill (`specs/multi_tenancy.md` §2). The ratchet is +what made that a required edit rather than an optional one: the +"gained a key, leave the baseline" rule below went red the moment the migration +landed, and stayed red until this docstring and the count agreed with it. + Tenancy was started and not carried through: `organization` exists with a single seeded row (`slug='default'`), `app_user` gained `organization_id` in migration 130, the CRM scoped three of its tables, and `org_group`/`org_role` @@ -47,9 +54,16 @@ "crm_deals", "org_group", "org_role", + # WS-29a — the whole Projects app, keyed while it was empty. + "pm_activities", "pm_custom_fields", "pm_notifications", + "pm_project_grants", "pm_projects", "pm_recurrences", "pm_tags", + "pm_task_assignees", "pm_task_attachments", "pm_task_counters", + "pm_task_links", "pm_task_personal", "pm_task_statuses", "pm_task_types", + "pm_tasks", "pm_view_task_positions", "pm_views", } -#: ⚠️ FROZEN 2026-08-08 at 137. Every table predating the multi-tenant decision. +#: ⚠️ FROZEN 2026-08-08 at 137, now 120 (WS-29a took the 17 `pm_*` out). +#: Every table predating the multi-tenant decision. #: Adding a name here is allowed and must come with a reason in the PR; adding #: one *silently* is how a 137 becomes a 160 without anybody choosing it. BASELINE_UNSCOPED = { @@ -122,11 +136,9 @@ # plugins_* "plugins", # pm_* - "pm_activities", "pm_custom_fields", "pm_notifications", - "pm_project_grants", "pm_projects", "pm_recurrences", "pm_tags", - "pm_task_assignees", "pm_task_attachments", "pm_task_counters", - "pm_task_links", "pm_task_personal", "pm_task_statuses", "pm_task_types", - "pm_tasks", "pm_view_task_positions", "pm_views", +# — all 17 left this baseline in WS-29a (migration 158). They are asserted +# as scoped by `EXPECTED_SCOPED` below, so their absence here is checked +# rather than merely assumed. # project_* "project", # provider_* @@ -252,6 +264,6 @@ def test_the_expected_scoped_set_is_real_not_aspirational() -> None: def test_the_frozen_count_matches_the_baseline() -> None: - """The docstring quotes 137. A baseline whose stated size and real size + """The docstring quotes 120. A baseline whose stated size and real size disagree is a baseline nobody trusts.""" - assert len(BASELINE_UNSCOPED) == 137 + assert len(BASELINE_UNSCOPED) == 120 From ae02ed7dc354c44315526e919398564eae94812f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 21:08:42 +0000 Subject: [PATCH 13/22] =?UTF-8?q?feat(WS-29a+b):=20the=20Projects=20tenant?= =?UTF-8?q?=20boundary=20=E2=80=94=20and=20my=20ratchet's=20own=20defect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes c52c34d4's `wip`. That commit was mine, made to satisfy a repo-hygiene hook mid-build; the agent doing the work reasonably flagged it as an unexplained third-party commit. Labelled NOT YET DONE at the time because it wasn't. It is now. WS-29a — organization_id NOT NULL REFERENCES organization(id) on all 17 pm_* tables, idempotent throughout. ⚠️ The "empty tables" premise in my brief was WRONG: the live database held 10 pm_tasks and 2 pm_projects of fixture residue from WS-27's own live runs, and SET NOT NULL does not care where a row came from. Backfilled to slug='default' first, and a missing default org fails the deploy loudly rather than guessing. The parent-consistency guard is a TRIGGER, not a CHECK, because a CHECK cannot read another table — Postgres refuses the subquery. One generic function with 21 attachments: it FILLS a NULL from the parent and REFUSES a mismatch. I tested both halves myself against live Postgres rather than taking them on trust: a pm_tasks row claiming org B under org A's project is refused with a message naming both organizations, and a NULL is filled from the parent. That trigger is the load-bearing design decision — it is why 43 INSERT sites across 16 modules did not have to grow a tenant argument. The app decides the tenant in exactly two places; the database derives the rest. WS-29b — Visibility carries organization_id, resolved from X-User-Email BEFORE the data:org:read check, and `vis_org` is always bound so None fails closed by SQL semantics rather than by an `if`. THE LEAK THE LIVE RUN FOUND, which the hermetic suite structurally could not: /projects/assigned-to-me and /projects/my/inbox have no visibility clause AT ALL — deliberately, because assignment IS the claim, so there was no grant clause to notice was missing and every grant-shaped test was already green. Under multi-tenancy that makes them reachable by typing an address: anyone in org B puts a member of org A on their task and its title, description and dates appear in that person's list. Worse than a read — WS-27e's personal mirror SYNCS /assigned-to-me into gtd_items, so the leak would be copied into a second app and outlive the request. Both now carry the tenant; the grant clause stays absent by design. Two more: attachments' `if not vis.unrestricted:` was correct while the unrestricted clause was TRUE and a whole-database leak once it became the tenant; and both importers matched an existing department by lower(name) with no tenant, which is a cross-tenant WRITE — org B's whole workspace poured into org A's department. 18/18 mutants killed, reverts byte-identical. Three survived the first pass and were fixed rather than accepted, two from one instructive class: the test regexes used a single space where the migration column-aligns, so `UPDATE pm_\w+ SET` matched exactly 1 of 17 statements. The tests now assert the match COUNT equals the table count. AND MY OWN DEFECT, found by the leak audit. test_tenancy_boundary.py matched the column NAME, so it counted crm_activities/crm_contacts/crm_deals as tenant-scoped on the strength of an organization_id that REFERENCES crm_organizations — a customer company. A guard satisfiable by a coincidence of naming is not a guard: any future table with an organization_id pointing anywhere would have passed silently. It now matches the foreign key's TARGET, and a probe table referencing crm_organizations is correctly refused. The three move into the baseline where they belong, so the count is 137 → 123 (−17 pm_*, +3 homonyms) and the debt was understated, not over. Verified by me, not reported to me: 1673 backend tests pass (775 in the projects/tenancy slice, from 661), 1106 frontend, ruff and xenon clean, the two-tenant live run re-executed here and green across 40 assertions including 404 — never 403 — for another tenant's task under data:org:read. Recorded, not smuggled in: clickup_id stays globally UNIQUE, so a second org importing the same ClickUp workspace now fails loudly on the constraint instead of writing into the first org's tree. Widening it needs an importer change, so it is written up in migration 158 §6. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/specs/multi_tenancy.md | 9 +- .../gateway/routes/projects/import_clickup.py | 6 +- .../gateway/gateway/routes/projects/me.py | 29 +- .../gateway/routes/projects/personal.py | 9 + tests/unit/_projects_fakes.py | 121 +++- tests/unit/test_projects_migration.py | 217 +++++++ tests/unit/test_projects_tenancy.py | 565 ++++++++++++++++++ tests/unit/test_tenancy_boundary.py | 52 +- 8 files changed, 975 insertions(+), 33 deletions(-) create mode 100644 tests/unit/test_projects_tenancy.py diff --git a/ai-company-brain/specs/multi_tenancy.md b/ai-company-brain/specs/multi_tenancy.md index 446f020a..70423e6b 100644 --- a/ai-company-brain/specs/multi_tenancy.md +++ b/ai-company-brain/specs/multi_tenancy.md @@ -20,7 +20,14 @@ which is a better starting position than it sounds and a worse one than it looks | App tables defined in migrations | **143** (plus `LiteLLM_*`, vendored, not ours) | | Carrying a real tenant key | **3** | | Carrying none | **140** | -| `pm_*` tables (Projects, WS-27) | 17 — **0 scoped** (WS-29a fixes this) | +| `pm_*` tables (Projects, WS-27) | 17 — **0 scoped** | + +**As of WS-29a (2026-08-08) that is 20 scoped and 123 unscoped**: the 17 `pm_*` tables were +keyed while they were nearly empty. "Nearly", not "entirely" — the premise that they held no +rows was wrong, and the live database had 10 `pm_tasks` and 2 `pm_projects` of fixture residue +from WS-27's own live runs. `SET NOT NULL` does not care where a row came from, so the +migration backfills to `slug='default'` first and fails the deploy loudly if that organization +is missing rather than guessing one. The three that are scoped: `app_user`, `org_group`, `org_role` — all `REFERENCES organization(id)`. diff --git a/apps/services/gateway/gateway/routes/projects/import_clickup.py b/apps/services/gateway/gateway/routes/projects/import_clickup.py index 883f66b7..952e1de2 100644 --- a/apps/services/gateway/gateway/routes/projects/import_clickup.py +++ b/apps/services/gateway/gateway/routes/projects/import_clickup.py @@ -373,9 +373,9 @@ async def _upsert_project( ⚠️ The key is ``(clickup_id, organization_id)`` here, not ``clickup_id`` alone (WS-29b). Without the tenant arm the second organization to import a - workspace would ADOPT the first one’s projects and then UPDATE their names + workspace would ADOPT the first one's projects and then UPDATE their names — a cross-tenant write that no read predicate sees. `clickup_id` is still - globally UNIQUE in the schema, so that organization’s import now fails on + globally UNIQUE in the schema, so that organization's import now fails on the constraint instead; migration 158 §6 records why widening it is a separate ticket. """ @@ -409,7 +409,7 @@ async def _upsert_project( "source": "import", "created_by": created_by, # A Space is a ROOT project, so the trigger has no parent to derive - # from. Folders and lists carry it too, and migration 158’s trigger then + # from. Folders and lists carry it too, and migration 158's trigger then # REFUSES the row if it disagrees with the parent it was grafted onto. "organization_id": organization_id, }) diff --git a/apps/services/gateway/gateway/routes/projects/me.py b/apps/services/gateway/gateway/routes/projects/me.py index 706cfbe2..b7123586 100644 --- a/apps/services/gateway/gateway/routes/projects/me.py +++ b/apps/services/gateway/gateway/routes/projects/me.py @@ -25,6 +25,7 @@ TaskModel, _get_db, actor, + resolve_organization_id, router, row_to_dict, ) @@ -39,10 +40,22 @@ async def assigned_to_me( ) -> ListResponse: """Tasks assigned to the caller, across every project they can reach. - **No visibility clause, on purpose.** Assignment is itself the strongest - claim to a task — ``load_visible_task`` already treats it that way — so - filtering this by project grants would hide work from the very person asked - to do it whenever it was delegated across a Center boundary. + **No GRANT clause, on purpose.** Assignment is itself the strongest claim + to a task — ``load_visible_task`` already treats it that way — so filtering + this by project grants would hide work from the very person asked to do it + whenever it was delegated across a Center boundary. + + ⚠️ **But there is a tenant clause, and it is not optional** (WS-29b). This + route reaches tasks by MATCHING A STRING: ``pm_task_assignees.assignee`` is + a bare email (D-PM-4) that nothing validates, so anyone in another + organization can put this caller's address on their task and — without the + line below — its title, description and dates appear here. Worse than a + read: WS-27e's personal mirror SYNCS this endpoint into ``gtd_items``, so + the leak would be copied into a second app and outlive the request. + + Found by driving this endpoint against a real two-tenant database. The + grant-based reads were all scoped by then; this one has no grant clause to + have noticed was missing. Done tasks are excluded by default. The completion boundary is read from the status ``category`` rather than from ``completed_at``, so a project that @@ -51,6 +64,7 @@ async def assigned_to_me( """ email = actor(user).lower() clauses = [ + "t.organization_id = CAST(:vis_org AS uuid)", "EXISTS (SELECT 1 FROM pm_task_assignees a " " WHERE a.task_id = t.id AND lower(a.assignee) = :who)", "t.archived_at IS NULL", @@ -65,8 +79,11 @@ async def assigned_to_me( db = await _get_db() try: + # A caller the directory does not know binds NULL and matches nothing, + # which is the same fail-closed shape every other read here has. + scope = {"who": email, "vis_org": await resolve_organization_id(db, email)} total = (await db.execute( - text(f"SELECT count(*) FROM pm_tasks t{where}"), {"who": email}, + text(f"SELECT count(*) FROM pm_tasks t{where}"), scope, )).scalar() or 0 rows = (await db.execute( text( @@ -74,7 +91,7 @@ async def assigned_to_me( f"ORDER BY t.due_at NULLS LAST, t.importance DESC NULLS LAST, " f"t.created_at DESC LIMIT :limit OFFSET :offset" ), - {"who": email, "limit": page.limit, "offset": page.offset}, + {**scope, "limit": page.limit, "offset": page.offset}, )).fetchall() return ListResponse( rows=[row_to_dict(r, TaskModel) for r in rows], total=int(total), diff --git a/apps/services/gateway/gateway/routes/projects/personal.py b/apps/services/gateway/gateway/routes/projects/personal.py index c2ea3843..1e6301e4 100644 --- a/apps/services/gateway/gateway/routes/projects/personal.py +++ b/apps/services/gateway/gateway/routes/projects/personal.py @@ -50,6 +50,7 @@ now, record_activity, require_organization_of, + resolve_organization_id, resolve_visibility, router, row_to_dict, @@ -376,6 +377,12 @@ def _personal_to_dict(row: Any) -> dict[str, Any]: #: The second arm matters — a task I captured and then unassigned is still mine #: to see; without it, clearing my own name off a private todo would make it #: vanish from the only place it exists. +#: +#: ⚠️ ``t.organization_id = :vis_org`` is composed ABOVE both arms (WS-29b), for +#: the same reason as ``me.assigned_to_me``: the first arm reaches tasks by +#: matching a bare, unvalidated email, so without it another organization can +#: place a row in this member's inbox by typing their address. The GRANT clause +#: is still deliberately absent — the tenant is not. _MY_TASKS_SQL = """ SELECT t.*, s.category AS status_category, @@ -397,6 +404,7 @@ def _personal_to_dict(row: Any) -> dict[str, Any]: ON p.task_id = t.id AND lower(p.member_email) = :who LEFT JOIN pm_projects proj ON proj.id = t.project_id WHERE t.archived_at IS NULL + AND t.organization_id = CAST(:vis_org AS uuid) AND ( EXISTS (SELECT 1 FROM pm_task_assignees a WHERE a.task_id = t.id AND lower(a.assignee) = :who) @@ -446,6 +454,7 @@ async def my_inbox( sql = _MY_TASKS_SQL + ("".join(f" AND {c}" for c in clauses)) db = await _get_db() try: + params["vis_org"] = await resolve_organization_id(db, email) rows = (await db.execute(text(sql), params)).fetchall() finally: await db.close() diff --git a/tests/unit/_projects_fakes.py b/tests/unit/_projects_fakes.py index 168aa654..4dda231c 100644 --- a/tests/unit/_projects_fakes.py +++ b/tests/unit/_projects_fakes.py @@ -19,6 +19,14 @@ would pass against an unscoped route — which is the whole defect class this package exists to avoid. +⚠️ **The tenant predicate is mirrored the same way** (WS-29b). Three shapes all +say ``organization_id`` — the grant closure's own arm, the ``data:org:read`` +subquery, and the outer ``AND`` composed above both — and each is applied only +when the statement carries THAT shape. A mirror that scoped everything by the +caller's organization regardless would agree with a route that dropped its +tenant clause, which is the leak ``specs/multi_tenancy.md`` §6 calls the most +dangerous line in the retrofit. + Its blind spots, stated so nobody reads a green suite as more than it is: * **Foreign keys and therefore cascades.** Deleting a ``pm_projects`` row leaves @@ -119,6 +127,13 @@ _CLOSURE_IS_TENANTED = re.compile( r"g\.organization_id\s*=\s*CAST\(:vis_org", re.I ) +#: ⚠️ And the closure's RECURSIVE step, read SEPARATELY from its seed step. +#: They are two predicates on two tables and a mirror that inferred one from +#: the other cannot see a mutant that deletes just the second — which is +#: exactly what happened, and this is the fix. +_DESCENT_IS_TENANTED = re.compile( + r"p\.organization_id\s*=\s*CAST\(:vis_org", re.I +) #: ``.organization_id = CAST(:vis_org AS uuid)`` — the tenant composed #: ABOVE the grant closure, and the whole of the unrestricted task clause. _ROW_TENANT = re.compile( @@ -136,6 +151,39 @@ #: tenancy is written against that deployment. DEFAULT_ORGANIZATION = "00000000-0000-4000-8000-0000000000aa" +#: Migration 158's trigger table, mirrored: ``table → (parent table, FK column)``. +#: +#: The DATABASE derives a child's `organization_id` from its parent on write, so +#: 43 INSERT sites in 16 modules did not have to grow a tenant argument. That +#: derivation has to happen here too, or every one of those inserts would land a +#: NULL and every scoped read of it would come back empty — which looks exactly +#: like the scoping working. +#: +#: Only the first parent is listed. The real trigger also declares SECOND +#: attachments (`pm_tasks.root_project_id`, `pm_task_links.target_task_id`, +#: `pm_view_task_positions.task_id`) whose job is to REFUSE a row straddling two +#: organizations. Those are a database constraint, and constraints are this +#: fake's stated blind spot — they are proved against a real Postgres. +_ORGANIZATION_PARENT: dict[str, tuple[str, str]] = { + "pm_projects": ("pm_projects", "parent_project_id"), + "pm_project_grants": ("pm_projects", "project_id"), + "pm_task_statuses": ("pm_projects", "project_id"), + "pm_task_types": ("pm_projects", "project_id"), + "pm_task_counters": ("pm_projects", "project_id"), + "pm_custom_fields": ("pm_projects", "project_id"), + "pm_tags": ("pm_projects", "project_id"), + "pm_recurrences": ("pm_projects", "project_id"), + "pm_views": ("pm_projects", "project_id"), + "pm_tasks": ("pm_projects", "project_id"), + "pm_activities": ("pm_tasks", "task_id"), + "pm_task_assignees": ("pm_tasks", "task_id"), + "pm_task_links": ("pm_tasks", "source_task_id"), + "pm_task_attachments": ("pm_tasks", "task_id"), + "pm_task_personal": ("pm_tasks", "task_id"), + "pm_notifications": ("pm_tasks", "task_id"), + "pm_view_task_positions": ("pm_views", "view_id"), +} + _SUBQUERY_RE = re.compile(r"\b(SELECT|WITH)\b", re.I) @@ -349,15 +397,15 @@ def seed(self, table: str, **columns: Any) -> SimpleNamespace: row = { "id": columns.pop("id", str(uuid4())), **_DEFAULTS.get(table, {}), - # WS-29a — every `pm_*` table carries the tenant key (D-MT-3), so a - # seeded row that lacked one would be invisible to every scoped read - # and would make the whole suite red for the wrong reason. An - # explicit `organization_id=` still wins: that is how the two-tenant - # tests put rows in the OTHER organization. - **({"organization_id": self.organization_id} - if table.startswith("pm_") else {}), **columns, } + # WS-29a — every `pm_*` table carries the tenant key (D-MT-3), so a + # seeded row that lacked one would be invisible to every scoped read and + # would make the whole suite red for the wrong reason. Derived from the + # parent exactly as the database does. An explicit `organization_id=` + # still wins: that is how the two-tenant tests place a row. + if table.startswith("pm_") and row.get("organization_id") is None: + row["organization_id"] = self.derive_organization(table, row) if table in _TIMESTAMPED: row.setdefault("created_at", _now() - timedelta(days=1)) row.setdefault("updated_at", _now() - timedelta(days=1)) @@ -367,6 +415,27 @@ def seed(self, table: str, **columns: Any) -> SimpleNamespace: def rows(self, table: str) -> list[dict[str, Any]]: return self.tables.get(table, []) + def derive_organization(self, table: str, row: dict[str, Any]) -> str | None: + """One row's tenant, the way migration 158's trigger derives it. + + The parent's value, or — for a ROOT project, which has no parent — this + fake's own organization, standing in for the value the application is + required to supply. ``pm_activities`` is the one row that may hang off + either a task or a project, so its second parent is tried too. + """ + parent = _ORGANIZATION_PARENT.get(table) + candidates = [parent] if parent else [] + if table == "pm_activities": + candidates.append(("pm_projects", "project_id")) + for parent_table, column in candidates: + parent_id = row.get(column) + if parent_id is None: + continue + for candidate in self.rows(parent_table): + if str(candidate.get("id")) == str(parent_id): + return candidate.get("organization_id") + return self.organization_id + def statements_touching(self, needle: str) -> list[str]: return [s for s in self.statements if needle in s] @@ -576,6 +645,10 @@ def _search_hits(self, statement: str, args: dict) -> list[Any]: str(args.get("vis_org")) if _CLOSURE_IS_TENANTED.search(statement) else None ), + descendant_organization_id=( + str(args.get("vis_org")) + if _DESCENT_IS_TENANTED.search(statement) else None + ), ) skips_archived = "t.archived_at IS NULL" in statement # Same rule as everywhere else: the tenant is applied only when the @@ -746,13 +819,10 @@ def _insert(self, statement: str, table: str, args: dict) -> _Result: row = {"id": str(uuid4()), **_DEFAULTS.get(table, {}), **values} # Migration 158's `pm_organization_from_parent` trigger, mirrored: a - # child row inserted without a tenant INHERITS one rather than being - # refused, which is what lets 43 INSERT sites stay unedited. The fake - # models a single-tenant deployment, so it inherits *the* organization - # instead of walking to a parent — the derivation itself is proved - # against a real Postgres, not here. + # child row inserted without a tenant INHERITS its parent's rather than + # being refused, which is what lets 43 INSERT sites stay unedited. if table.startswith("pm_") and row.get("organization_id") is None: - row["organization_id"] = self.organization_id + row["organization_id"] = self.derive_organization(table, row) if table in _TIMESTAMPED: row.setdefault("created_at", _now()) row.setdefault("updated_at", _now()) @@ -834,6 +904,7 @@ def _select(self, statement: str, table: str, args: dict) -> _Result: def visible_project_ids( self, email: str, groups: list[str], organization_id: str | None = None, + descendant_organization_id: str | None = None, ) -> set[str]: """The grant closure: directly granted projects, plus their descendants. @@ -846,6 +917,12 @@ def visible_project_ids( (or the closure itself) that loses its tenant filter stops being scoped here too and the cross-tenant test goes red. Defaulting it to the fake's own organization would have made the leak invisible. + + ⚠️ The SEED step and the DESCENT step are told apart, and they are two + separate arguments for that reason. Inferring the second from the first + let a mutant delete the recursive term's tenant filter and survive a + green suite — the descent is the arm that would matter most if the + database's parent-consistency trigger were ever dropped. """ wanted = {str(g).lower() for g in groups} seeds = { @@ -866,8 +943,9 @@ def visible_project_ids( changed = False for project in self.rows("pm_projects"): parent = project.get("parent_project_id") - if organization_id is not None and ( - str(project.get("organization_id")) != organization_id + if descendant_organization_id is not None and ( + str(project.get("organization_id")) + != descendant_organization_id ): continue if parent is not None and str(parent) in out and str(project["id"]) not in out: @@ -922,11 +1000,20 @@ def _inbox_rows(self, statement: str, args: dict) -> list[Any]: # module's docstring warns about, caught by mutation rather than review. wants_assigned = "lower(a.assignee) = :who" in statement wants_personal = "lower(proj.personal_owner) = :who" in statement + # ⚠️ WS-29b's tenant, composed above both arms. Read off the statement + # like everything else here: the inbox has no GRANT clause by design, so + # this line is the ONLY thing standing between it and another + # organization's task, and a mirror that applied it unconditionally + # could not tell whether the route still emits it. + tenanted = "t.organization_id = CAST(:vis_org AS uuid)" in statement + org = str(args.get("vis_org")) out: list[Any] = [] for task in self.rows("pm_tasks"): if task.get("archived_at") is not None: continue + if tenanted and str(task.get("organization_id")) != org: + continue assignees = self._assignees_of(task["id"]) reached = (wants_assigned and who in assignees) or ( wants_personal and str(task.get("project_id")) in personal_projects @@ -1038,6 +1125,10 @@ def _apply_subqueries( str(args.get("vis_org")) if _CLOSURE_IS_TENANTED.search(where) else None ), + descendant_organization_id=( + str(args.get("vis_org")) + if _DESCENT_IS_TENANTED.search(where) else None + ), ) column_match = _IN_SUBQUERY.search(where) column = column_match.group(1) if column_match else "id" diff --git a/tests/unit/test_projects_migration.py b/tests/unit/test_projects_migration.py index a4bc7fac..c1214be0 100644 --- a/tests/unit/test_projects_migration.py +++ b/tests/unit/test_projects_migration.py @@ -325,3 +325,220 @@ def test_every_activity_type_the_routes_write_is_in_the_vocabulary() -> None: f"routes write types the vocabulary refuses: " f"{sorted(used - set(ACTIVITY_TYPES))}" ) + + +# ── The tenant key (WS-29a, migration 158) ────────────────────────────────── +# +# Same rules as everything above, applied to the SECOND file that defines the +# `pm_*` shape. Found by content for the same reason (R1 forbids pinning a +# number), and read with comments stripped for the same reason: this migration +# explains itself at length and an assertion its own prose can satisfy is not an +# assertion. +# +# Spec: ai-company-brain/specs/multi_tenancy.md §3 (D-MT-1 (a), D-MT-3). + +#: Every table §3 specifies, plus the six added by 147/150/152/155/156/157. +#: Listed rather than derived, so a table quietly dropped from the tenant +#: migration fails here instead of shrinking the expectation with it. +TENANT_SCOPED_TABLES: tuple[str, ...] = ( + *EXPECTED_TABLES, + "pm_task_personal", + "pm_task_attachments", + "pm_notifications", + "pm_custom_fields", + "pm_tags", + "pm_recurrences", +) + + +def _tenancy_migration() -> Path: + """The migration that gives ``pm_projects`` its tenant key.""" + found = [ + path for path in sorted(MIGRATIONS.glob("*.sql")) + if path.name != "schema.generated.sql" + and re.search( + r"ALTER TABLE pm_projects\s+ADD COLUMN IF NOT EXISTS organization_id", + path.read_text(encoding="utf-8"), + ) + ] + assert len(found) == 1, ( + f"expected exactly one migration adding pm_projects.organization_id, " + f"found {[p.name for p in found]}" + ) + return found[0] + + +@pytest.fixture(scope="module") +def tenancy(request: pytest.FixtureRequest) -> str: + raw = _tenancy_migration().read_text(encoding="utf-8") + return "\n".join(re.sub(r"--.*$", "", line) for line in raw.splitlines()) + + +@pytest.mark.parametrize("table", TENANT_SCOPED_TABLES) +def test_every_pm_table_gains_the_tenant_key(tenancy: str, table: str) -> None: + """D-MT-3: the key is carried on EVERY tenant-owned table, even where it is + derivable. A missing one is a table whose rows belong to nobody — and RLS, + when D-MT-2 answers, cannot police what it cannot read off the row.""" + assert re.search( + rf"ALTER TABLE {table}\s+ADD COLUMN IF NOT EXISTS organization_id\s+UUID", + tenancy, + ), f"{table} gains no organization_id" + + +@pytest.mark.parametrize("table", TENANT_SCOPED_TABLES) +def test_every_tenant_key_is_not_null(tenancy: str, table: str) -> None: + """A nullable tenant key is a row belonging to nobody, which is either + invisible to everybody or visible to everybody depending on how the + predicate is written. Neither is an answer.""" + assert re.search( + rf"ALTER TABLE {table}\s+ALTER COLUMN organization_id SET NOT NULL", + tenancy, + ), f"{table}.organization_id may be NULL" + + +@pytest.mark.parametrize("table", TENANT_SCOPED_TABLES) +def test_every_tenant_key_cascades_from_its_organization( + tenancy: str, table: str, +) -> None: + """Same posture as `app_user`, `org_group` and `org_role` (§6): deleting an + organization takes its rows with it, rather than leaving orphans pointing at + an id nothing resolves.""" + block = re.search( + rf"ALTER TABLE {table}\s+ADD COLUMN IF NOT EXISTS organization_id[^;]*;", + tenancy, + ) + assert block is not None + assert re.search( + r"REFERENCES organization \(id\) ON DELETE CASCADE", block.group(0), + ), f"{table}.organization_id is not a cascading FK onto organization" + + +@pytest.mark.parametrize("table", TENANT_SCOPED_TABLES) +def test_every_pm_table_is_backfilled_before_the_constraint( + tenancy: str, table: str, +) -> None: + """§2 predicted these tables were empty; the live database said otherwise. + + A `SET NOT NULL` on a table with one un-backfilled row fails the whole + deploy, so the fill is not optional and is not conditional on the prediction + having been right. + """ + fill = re.search(rf"UPDATE {table}\s+SET organization_id", tenancy) + constrain = re.search( + rf"ALTER TABLE {table}\s+ALTER COLUMN organization_id SET NOT NULL", + tenancy, + ) + assert fill is not None, f"{table} is never backfilled" + assert constrain is not None + assert constrain.start() > fill.start(), ( + f"{table} is constrained before it is filled" + ) + + +def test_the_backfill_names_the_default_organization_and_nothing_else( + tenancy: str, +) -> None: + """`slug='default'` is the one seeded row (migration 130). Picking a row by + ORDER BY, or inventing one, would be a silent guess at which organization + owns somebody's work — worse than a failed migration.""" + fills = re.findall( + r"UPDATE pm_\w+\s+SET organization_id = \(([^)]*)\)", tenancy, + ) + # ⚠️ `\s+`, not a single space: the statements are column-aligned, and a + # regex demanding one space silently matched exactly ONE of the seventeen — + # found by a mutant that deleted a guard from the other sixteen and lived. + assert len(fills) == len(TENANT_SCOPED_TABLES), ( + f"expected {len(TENANT_SCOPED_TABLES)} backfills, matched {len(fills)}" + ) + for source in fills: + assert source.strip() == "SELECT id FROM organization WHERE slug = 'default'" + + +def test_the_backfill_reruns_as_a_no_op(tenancy: str) -> None: + """The runner replays this on every deploy. Without the WHERE, a second run + would rewrite every row — including any a later ticket had deliberately + moved to another organization.""" + statements = re.findall(r"UPDATE pm_\w+\s+SET organization_id[^;]*;", tenancy) + assert len(statements) == len(TENANT_SCOPED_TABLES), ( + f"expected {len(TENANT_SCOPED_TABLES)} backfills, matched " + f"{len(statements)} — the alignment defeated the pattern" + ) + for statement in statements: + assert "WHERE organization_id IS NULL" in statement, statement + + +def test_a_childs_tenant_is_checked_against_its_parents(tenancy: str) -> None: + """D-MT-3 names this as the cost of carrying the key on every row. + + ⚠️ It cannot be a CHECK — a CHECK constraint may only read its own row, and + Postgres refuses a subquery in one. A BEFORE trigger is the only in-database + mechanism that can compare against another table, so the guard the spec asks + for is a trigger and the migration says why. + """ + assert "CREATE OR REPLACE FUNCTION pm_organization_from_parent()" in tenancy + # The FILL half… + assert re.search( + r"IF NEW\.organization_id IS NULL THEN\s+NEW\.organization_id := parent_org", + tenancy, + ), "the trigger does not derive a missing tenant from the parent" + # …and the REFUSE half, which is the one a mutant can hollow out while + # leaving a `RAISE EXCEPTION` in the file for a checker to find. The + # COMPARISON is the guard, not the raise. + assert re.search( + r"ELSIF NEW\.organization_id <> parent_org THEN\s+RAISE EXCEPTION", + tenancy, + ), "a child may carry a tenant that disagrees with its parent's" + + +def test_the_trigger_is_declared_replaceably(tenancy: str) -> None: + """`CREATE TRIGGER` has no `IF NOT EXISTS`; a plain one fails the second + deploy, which is the deploy nobody watches.""" + plain = re.findall(r"CREATE\s+TRIGGER\s+\S+", tenancy, re.I) + assert not plain, f"CREATE TRIGGER without OR REPLACE: {plain}" + assert len(re.findall(r"CREATE OR REPLACE TRIGGER", tenancy)) >= len( + TENANT_SCOPED_TABLES + ) + + +@pytest.mark.parametrize("table", TENANT_SCOPED_TABLES) +def test_every_pm_table_derives_its_tenant_from_a_parent( + tenancy: str, table: str, +) -> None: + """The FILL half, and the reason 43 INSERT sites did not have to change. + + A table with no attachment is a table whose every insert must remember the + key by hand — D-MT-2 (b)'s named failure mode, and the discipline that + produced 137 unscoped tables in the first place. + """ + assert re.search( + rf"BEFORE INSERT OR UPDATE ON {table}\b", tenancy, + ), f"{table} has no tenant-derivation trigger" + + +def test_the_two_rows_that_name_two_parents_verify_both(tenancy: str) -> None: + """⚠️ A link and a view position each name TWO rows, so each is a row that + could STRADDLE two organizations. One attachment fills; the second is what + makes the straddle impossible rather than merely unlikely.""" + for table, columns in ( + ("pm_task_links", ("source_task_id", "target_task_id")), + ("pm_view_task_positions", ("view_id", "task_id")), + # A task's denormalised root must live where the project it sits in does. + ("pm_tasks", ("project_id", "root_project_id")), + # An activity may hang off either, and when both are set they must agree. + ("pm_activities", ("task_id", "project_id")), + ): + block = tenancy.split(f"BEFORE INSERT OR UPDATE ON {table}\n") + assert len(block) == 3, f"{table} does not have exactly two attachments" + for column in columns: + assert f"'{column}')" in tenancy, f"{table} never verifies {column}" + + +def test_no_row_level_security_is_declared(tenancy: str) -> None: + """⚠️ D-MT-2 is OPEN. RLS is *proposed*, not decided, and shipping a policy + here would settle by default a decision the spec says is unsettled — while + requiring a GUC that no connection in this system sets.""" + shouted = tenancy.upper() + for forbidden in ("ROW LEVEL SECURITY", "CREATE POLICY", "CURRENT_SETTING"): + assert forbidden not in shouted, ( + f"the tenancy migration declares {forbidden}; D-MT-2 is open" + ) diff --git a/tests/unit/test_projects_tenancy.py b/tests/unit/test_projects_tenancy.py new file mode 100644 index 00000000..b164593e --- /dev/null +++ b/tests/unit/test_projects_tenancy.py @@ -0,0 +1,565 @@ +"""Projects · the TENANT boundary — what one organization cannot see (WS-29b). + +Spec: ``ai-company-brain/specs/multi_tenancy.md`` §3 (D-MT-1 (a), D-MT-3) and +§6. Schema: migration 158. + +``test_projects_grants.py`` is the fence between two *departments of one +company*. This is the fence between two *companies*, and it is a different +question with a different failure mode: a grant bug shows somebody the wrong +project, a tenancy bug shows somebody another business's entire portfolio. + +**Every test here seeds TWO organizations**, because a one-organization suite +cannot tell a scoped route from an unscoped one — the same reason +``test_projects_grants.py`` never tests as the owner. + +⚠️ **The two lines §6 calls the retrofit's most dangerous** are pinned +individually, because each of them was CORRECT before this ticket and becomes a +cross-tenant leak the day a second organization exists: + +1. ``pm_project_grants.subject = 'org'`` meant "everybody". It must mean + "everybody **in this organization**". +2. ``data:org:read`` short-circuited to ``unrestricted=True``, whose clause was + the literal ``TRUE``. It must mean unrestricted **within a tenant**. + +And a third this suite adds, which §6 does not name: ``pm_task_assignees`` +holds a bare email (D-PM-4) and ``load_visible_task``'s second arm matches on +it. Nothing stops organization B typing organization A's member into it, so the +tenant has to be composed ABOVE that arm rather than inside the grant closure. + +Hermetic: no Postgres, no network, no TestClient. The database's own half — the +``pm_organization_from_parent`` trigger that derives a child's tenant and +refuses a mismatched one — is proved against a real Postgres, not here; this +file's fake fills the column the way the trigger does and says so. +""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import activities as pm_activities +from gateway.routes.projects import admin as pm_admin +from gateway.routes.projects import core as pm_core +from gateway.routes.projects import me as pm_me +from gateway.routes.projects import personal as pm_personal +from gateway.routes.projects import search as pm_search +from gateway.routes.projects import tasks as pm_tasks +from gateway.routes.projects import tree as pm_tree +from gateway.routes.projects import views as pm_views + +from tests.unit._projects_fakes import ( + DEFAULT_ORGANIZATION, + FakeProjectsDB, + bind_db, + member_user, + page, + projects_user, + silence_events, +) + +MODULES = ( + pm_core, pm_tree, pm_tasks, pm_activities, pm_admin, pm_views, pm_me, + pm_search, pm_personal, +) + +#: Two organizations, and the ids are readable so a failure message says which. +ORG_A = DEFAULT_ORGANIZATION +ORG_B = "00000000-0000-4000-8000-0000000000bb" + +#: One person each. D-MT-1 (a): one email, one person, one organization — which +#: is exactly why the tenant is derivable from `X-User-Email` alone. +ANA = member_user("ana@alpha.example") +BEN = member_user("ben@beta.example") + +#: ⚠️ The same address in both directories. Structurally impossible under +#: D-MT-1 (a) (`app_user.email` is globally UNIQUE) — used only where a test +#: needs to prove that a route reads the ORGANIZATION and not the string. +BOSS_A = projects_user("boss@alpha.example") + + +@pytest.fixture +def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB: + """Two tenants, two directory rows, and no accidental default. + + ``organization_id = None`` on the fake so that a caller who is NOT seeded + into ``app_user`` resolves to nothing — a suite where the fallback quietly + supplied a tenant would prove nothing about the lookup. + """ + fake = FakeProjectsDB() + fake.organization_id = None + fake.seed("app_user", email="ana@alpha.example", status="active", + organization_id=ORG_A) + fake.seed("app_user", email="boss@alpha.example", status="active", + organization_id=ORG_A) + fake.seed("app_user", email="ben@beta.example", status="active", + organization_id=ORG_B) + bind_db(monkeypatch, fake, MODULES) + silence_events(monkeypatch, MODULES) + return fake + + +def _no_groups(monkeypatch: pytest.MonkeyPatch, *groups: str) -> None: + """Pin group membership, carrying the tenant through. + + Same helper as ``test_projects_grants.py``: the group lookup joins tables + belonging to the access system, which this app's fake does not model. + """ + real = pm_core.resolve_visibility + + async def _resolve(db, user): + vis = await real(db, user) + if vis.unrestricted: + return vis + return pm_core.Visibility( + unrestricted=False, email=vis.email, groups=tuple(groups), + organization_id=vis.organization_id, + ) + + for module in MODULES: + monkeypatch.setattr(module, "resolve_visibility", _resolve, raising=False) + + +def _two_tenants(db: FakeProjectsDB, *, subject: str = "org") -> tuple: + """One project per organization, each granted the SAME way. + + Granting both identically is the point: if the two are distinguishable + afterwards, only the tenant can have distinguished them. + """ + alpha = db.seed_project(name="Alpha work", subject=subject, + organization_id=ORG_A) + beta = db.seed_project(name="Beta work", subject=subject, + organization_id=ORG_B) + return alpha, beta + + +# ── ⚠️ Leak 1: `subject = 'org'` ──────────────────────────────────────────── + +async def test_an_org_grant_reaches_only_the_granting_organization( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """⚠️ THE line. `subject = 'org'` means "everybody"; multi_tenancy.md §6 + calls making it mean "everybody in THIS organization" the single most + dangerous edit in the retrofit — "today it is correct, and after the first + second tenant onboards it is a cross-tenant leak". + + What breaks without it: every project in the deployment is org-granted by + default (`create_node` writes that grant itself), so a caller in ANY + organization sees the entire database. + """ + _no_groups(monkeypatch) + alpha, beta = _two_tenants(db) + + assert (await pm_tree.get_node(str(alpha.id), user=ANA))["id"] == str(alpha.id) + with pytest.raises(HTTPException) as exc: + await pm_tree.get_node(str(beta.id), user=ANA) + assert exc.value.status_code == 404 + + +async def test_the_project_list_shows_one_organization_at_a_time( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The portfolio read, which is the one a person actually opens.""" + _no_groups(monkeypatch) + _two_tenants(db) + + for user, expected in ((ANA, ["Alpha work"]), (BEN, ["Beta work"])): + listed = await pm_tree.list_nodes(user=user) + assert sorted(r["name"] for r in listed["rows"]) == expected + + +async def test_an_email_grant_does_not_cross_the_tenant_either( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """⚠️ The parenthesis test, stated as behaviour. + + `WHERE org = :o AND (a OR b OR c)` and `WHERE org = :o AND a OR b OR c` are + one character apart and Postgres accepts both. The second scopes the + `subject = 'org'` arm alone and leaves the email and group arms wide open — + the same leak wearing a subtler hat, and the one a reader's eye skips. + """ + _no_groups(monkeypatch) + db.seed_project(name="Beta private", subject="ana@alpha.example", + organization_id=ORG_B) + + listed = await pm_tree.list_nodes(user=ANA) + assert [r["name"] for r in listed["rows"]] == [] + + +async def test_a_group_grant_does_not_cross_the_tenant_either( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The third arm, for the same reason as the second. Group slugs are not + unique across organizations — every company has a `group:sales`.""" + _no_groups(monkeypatch, "group:sales") + db.seed_project(name="Beta sales", subject="group:sales", + organization_id=ORG_B) + db.seed_project(name="Alpha sales", subject="group:sales", + organization_id=ORG_A) + + listed = await pm_tree.list_nodes(user=ANA) + assert [r["name"] for r in listed["rows"]] == ["Alpha sales"] + + +async def test_a_granted_subtree_stops_at_the_tenant( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The closure's RECURSIVE step carries the tenant too. + + Migration 158's trigger already makes a cross-tenant parent impossible, so + this is the defence-in-depth arm: the closure must not be the thing that + would leak if that trigger were ever dropped. + """ + _no_groups(monkeypatch) + alpha = db.seed_project(name="Alpha root", subject="org", + organization_id=ORG_A) + db.seed_project(name="Smuggled", subject=None, parent=str(alpha.id), + organization_id=ORG_B) + + listed = await pm_tree.list_nodes(user=ANA) + assert sorted(r["name"] for r in listed["rows"]) == ["Alpha root"] + + +# ── ⚠️ Leak 2: `data:org:read` ────────────────────────────────────────────── + +async def test_org_read_is_unrestricted_within_a_tenant_not_across_them( + db: FakeProjectsDB, +) -> None: + """⚠️ The second leak §6 does not name but §3 implies. + + `data:org:read` is "the permission that opens the whole portfolio". Whose + portfolio was never a question worth asking while there was one + organization. Both clause helpers answered the literal `TRUE` for this + caller; `TRUE` is every row in the table. + """ + alpha = db.seed_project(name="Alpha work", subject=None, + organization_id=ORG_A) + beta = db.seed_project(name="Beta work", subject=None, + organization_id=ORG_B) + + # Ungranted in their OWN organization, and still visible — that is what + # `data:org:read` buys, and it must keep working. + assert (await pm_tree.get_node(str(alpha.id), user=BOSS_A))["id"] == str(alpha.id) + + with pytest.raises(HTTPException) as exc: + await pm_tree.get_node(str(beta.id), user=BOSS_A) + assert exc.value.status_code == 404 + + +async def test_an_org_read_holders_task_list_stops_at_their_tenant( + db: FakeProjectsDB, +) -> None: + """`task_visibility_clause`'s unrestricted arm, which was `TRUE`.""" + alpha = db.seed_project(name="Alpha", subject=None, organization_id=ORG_A) + beta = db.seed_project(name="Beta", subject=None, organization_id=ORG_B) + a_status = db.seed_status(str(alpha.id)) + b_status = db.seed_status(str(beta.id)) + db.seed_task(str(alpha.id), str(a_status.id), title="Ours") + db.seed_task(str(beta.id), str(b_status.id), title="Theirs") + + listed = await pm_tasks.list_tasks(user=BOSS_A, page=page()) + assert [r["title"] for r in listed.rows] == ["Ours"] + + +async def test_search_does_not_reach_across_the_tenant_for_anybody( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Search is the widest read in the app — it deliberately spans every + project the caller can see, so it is where a missing tenant costs most. + Checked for BOTH principals, because they take different arms of the clause. + """ + _no_groups(monkeypatch) + alpha = db.seed_project(name="Alpha", subject="org", organization_id=ORG_A) + beta = db.seed_project(name="Beta", subject="org", organization_id=ORG_B) + db.seed_task(str(alpha.id), str(db.seed_status(str(alpha.id)).id), + title="Quarterly margin review") + db.seed_task(str(beta.id), str(db.seed_status(str(beta.id)).id), + title="Quarterly margin secrets") + + for user in (ANA, BOSS_A): + hits = await pm_search.search_tasks(q="quarterly", user=user) + assert [r["title"] for r in hits["rows"]] == ["Quarterly margin review"] + + +# ── ⚠️ Leak 3: the assignee escape hatch ─────────────────────────────────── + +async def test_being_named_as_an_assignee_in_another_tenant_grants_nothing( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """⚠️ The leak §6 does not name. + + `load_visible_task`'s second arm exists so a task delegated ACROSS a Center + boundary is still openable by the person expected to do it — matched on + `lower(a.assignee) = :vis_email`, a bare string (D-PM-4). + + Nothing validates that string. Anyone in organization B can type + `ana@alpha.example` into it, and without the tenant composed ABOVE the two + arms that row hands Ana the task's title, description and timeline. This is + why the clause is `(tenant AND (grants OR assigned))` and not + `(tenant AND grants) OR assigned`. + """ + _no_groups(monkeypatch) + beta = db.seed_project(name="Beta", subject=None, organization_id=ORG_B) + task = db.seed_task(str(beta.id), str(db.seed_status(str(beta.id)).id), + title="Their acquisition memo") + db.seed("pm_task_assignees", task_id=str(task.id), + assignee="ana@alpha.example", assigned_by="ben@beta.example", + organization_id=ORG_B) + + with pytest.raises(HTTPException) as exc: + await pm_tasks.get_task(str(task.id), user=ANA) + assert exc.value.status_code == 404 + + listed = await pm_tasks.list_tasks(user=ANA, page=page()) + assert [r["title"] for r in listed.rows] == [] + + +async def test_an_assignee_in_the_SAME_tenant_still_sees_the_task( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The other direction, and the reason the arm exists at all. + + Without this the tenant predicate would look correct while having quietly + removed cross-Center delegation — a feature, not a leak, and one the + previous test alone cannot tell apart from a route that dropped the arm. + """ + _no_groups(monkeypatch) + alpha = db.seed_project(name="Alpha finance", subject=None, + organization_id=ORG_A) + task = db.seed_task(str(alpha.id), str(db.seed_status(str(alpha.id)).id), + title="One thing for Finance") + db.seed("pm_task_assignees", task_id=str(task.id), + assignee="ana@alpha.example", assigned_by="boss@alpha.example", + organization_id=ORG_A) + + row = await pm_tasks.get_task(str(task.id), user=ANA) + assert row["title"] == "One thing for Finance" + + +# ── The resolver ──────────────────────────────────────────────────────────── + +async def test_the_tenant_comes_from_the_directory_not_from_the_request( + db: FakeProjectsDB, +) -> None: + """D-MT-1 (a). `X-User-Email` → `app_user.organization_id`, and nothing the + caller sends can influence it — which is the whole reason (a) was safe to + take without touching any app's auth seam.""" + vis = await pm_core.resolve_visibility(db, ANA) + assert vis.organization_id == ORG_A + assert vis.params["vis_org"] == ORG_A + + +async def test_org_read_does_not_short_circuit_the_tenant_lookup( + db: FakeProjectsDB, +) -> None: + """⚠️ Order-of-operations, pinned. `data:org:read` short-circuits the GROUP + lookup — groups cannot change an unrestricted answer. It must not + short-circuit the ORGANIZATION lookup, or the clause binds `:vis_org = NULL` + and either fails closed for the wrong reason or, worse, is written back to + `TRUE` by whoever debugs it. + """ + vis = await pm_core.resolve_visibility(db, BOSS_A) + assert vis.unrestricted is True + assert vis.organization_id == ORG_A + assert vis.params["vis_org"] == ORG_A + + +async def test_a_caller_the_directory_does_not_know_sees_nothing( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fail CLOSED, and by construction rather than by a check: every clause + compares to `CAST(:vis_org AS uuid)`, and `column = NULL` is NULL in SQL. + + This is the shape a service identity or a stale session takes. It must see + nothing rather than everything, and it must not need an `if` to do so. + """ + _no_groups(monkeypatch) + _two_tenants(db) + stranger = member_user("nobody@nowhere.example") + + vis = await pm_core.resolve_visibility(db, stranger) + assert vis.organization_id is None + listed = await pm_tree.list_nodes(user=stranger) + assert listed["rows"] == [] + + +async def test_an_org_read_holder_the_directory_does_not_know_sees_nothing( + db: FakeProjectsDB, +) -> None: + """The dangerous combination: the widest permission and no tenant. `TRUE` + would have shown them everything; the tenant clause shows them nothing.""" + _two_tenants(db, subject=None) + ghost = projects_user("ghost@nowhere.example") + + listed = await pm_tree.list_nodes(user=ghost) + assert listed["rows"] == [] + + +# ── Writes ────────────────────────────────────────────────────────────────── + +async def test_creating_a_root_project_stamps_the_callers_organization( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The ONE decision point. `pm_projects` is the root of every other `pm_*` + row, so this is the only value the database cannot derive.""" + _no_groups(monkeypatch) + created = await pm_tree.create_node(pm_tree.ProjectIn(name="New"), user=ANA) + + row = next(p for p in db.rows("pm_projects") if str(p["id"]) == created["id"]) + assert row["organization_id"] == ORG_A + # …and the grant it writes for itself is in the same organization, or the + # project would be invisible to the person who just created it. + grant = next(g for g in db.rows("pm_project_grants") + if str(g["project_id"]) == created["id"]) + assert grant["organization_id"] == ORG_A + + +async def test_a_caller_with_no_organization_cannot_create_a_project( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """403, not 404 (and not a 500 from `NOT NULL`). + + R5's 404 rule is about RECORDS — it exists so an error code cannot be used + to probe what exists elsewhere. This says nothing about any record: it is + the caller's own account that is not set up, and answering 404 would send + somebody hunting for a project that was never created. + """ + _no_groups(monkeypatch) + with pytest.raises(HTTPException) as exc: + await pm_tree.create_node( + pm_tree.ProjectIn(name="Orphan"), + user=member_user("nobody@nowhere.example"), + ) + assert exc.value.status_code == 403 + assert not db.rows("pm_projects") + + +async def test_a_capture_creates_the_personal_project_in_the_callers_tenant( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The second (and last) place that decides a tenant: a personal project is + a ROOT project, so nothing upstream can supply one.""" + _no_groups(monkeypatch) + await pm_personal.capture(pm_personal.CaptureIn(title="Think"), user=ANA) + + project = next(p for p in db.rows("pm_projects") + if p.get("personal_owner") == "ana@alpha.example") + assert project["organization_id"] == ORG_A + + +async def test_a_hidden_project_cannot_be_used_as_a_parent_across_tenants( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Writing is a way of reading. Grafting onto another organization's + project would inherit its grants — access widened by writing rather than by + being granted — so the parent must be visible first, and the tenant is what + makes it invisible.""" + _no_groups(monkeypatch) + beta = db.seed_project(name="Beta", subject="org", organization_id=ORG_B) + + with pytest.raises(HTTPException) as exc: + await pm_tree.create_node( + pm_tree.ProjectIn(name="Wedge", parent_project_id=str(beta.id)), + user=ANA, + ) + assert exc.value.status_code == 404 + + +# ── The clause, read as text ──────────────────────────────────────────────── +# +# Behaviour is the real assertion; these two read the SQL because the mirror can +# only ever agree with itself, and the shape of these particular clauses is what +# a reviewer's eye is worst at. + +def test_the_closure_scopes_all_three_subject_arms_together() -> None: + """⚠️ The parenthesis, structurally. `AND` binds tighter than `OR`, so + without the brackets the tenant applies to `subject = 'org'` alone.""" + sql = " ".join(pm_core._VISIBLE_PROJECTS_SQL.split()) + assert ( + "WHERE g.organization_id = CAST(:vis_org AS uuid) AND (g.subject = 'org'" + in sql + ) + assert "ANY(:vis_groups))" in sql + + +def test_the_task_clause_puts_the_tenant_outside_both_arms() -> None: + """⚠️ `(tenant AND (grants OR assigned))`, never + `(tenant AND grants) OR assigned` — the second leaves the assignee escape + hatch reachable from any organization.""" + vis = pm_core.Visibility( + unrestricted=False, email="ana@alpha.example", groups=(), + organization_id=ORG_A, + ) + clause = " ".join(pm_core.task_visibility_clause(vis).split()) + assert clause.startswith("(t.organization_id = CAST(:vis_org AS uuid) AND (") + assert clause.endswith(")))") + + +def test_no_clause_helper_can_answer_the_literal_TRUE() -> None: + """The regression this whole file exists to prevent, in one line. + + `TRUE` was the unrestricted answer from both helpers before WS-29b, and it + is the answer somebody reaches for when a tenant clause is inconvenient. + """ + for unrestricted in (True, False): + vis = pm_core.Visibility( + unrestricted=unrestricted, email="ana@alpha.example", groups=(), + organization_id=ORG_A, + ) + assert vis.project_clause() != "TRUE" + assert pm_core.task_visibility_clause(vis) != "TRUE" + assert vis.params["vis_org"] == ORG_A + + +# ── ⚠️ The two reads with NO grant clause at all ──────────────────────────── +# +# `/assigned-to-me` and `/my/inbox` deliberately carry no visibility clause: +# assignment IS the claim, and filtering them by project grants would hide work +# from the person asked to do it. That makes them the two routes where the +# tenant is the only fence there is — and the two the grant-shaped tests above +# cannot cover, because there is no grant to get wrong. +# +# Both were found unscoped by driving them against a real two-tenant database +# after every grant-based read was already green. + +async def test_assigned_to_me_does_not_import_another_tenants_work( + db: FakeProjectsDB, +) -> None: + """⚠️ The worst of the three leaks, because it does not stop at a response. + + WS-27e's personal mirror SYNCS this endpoint into the Tasks app's + ``gtd_items``. A row organization B can create by typing an address would + therefore be COPIED into Ana's personal task manager and outlive the + request that leaked it. + """ + beta = db.seed_project(name="Beta", subject=None, organization_id=ORG_B) + theirs = db.seed_task(str(beta.id), str(db.seed_status(str(beta.id)).id), + title="Their acquisition memo") + db.seed("pm_task_assignees", task_id=str(theirs.id), + assignee="ana@alpha.example", assigned_by="ben@beta.example", + organization_id=ORG_B) + + alpha = db.seed_project(name="Alpha", subject=None, organization_id=ORG_A) + mine = db.seed_task(str(alpha.id), str(db.seed_status(str(alpha.id)).id), + title="My actual work") + db.seed("pm_task_assignees", task_id=str(mine.id), + assignee="ana@alpha.example", assigned_by="boss@alpha.example", + organization_id=ORG_A) + + listed = await pm_me.assigned_to_me(user=ANA, page=page()) + assert [r["title"] for r in listed.rows] == ["My actual work"] + + +async def test_my_inbox_does_not_import_another_tenants_work( + db: FakeProjectsDB, +) -> None: + """The GTD inbox, same shape and same reason. Its first arm is the same + unvalidated string match; its second (my personal project) is safe only + because `personal_owner` is written from the session.""" + beta = db.seed_project(name="Beta", subject=None, organization_id=ORG_B) + theirs = db.seed_task(str(beta.id), str(db.seed_status(str(beta.id)).id), + title="Their acquisition memo") + db.seed("pm_task_assignees", task_id=str(theirs.id), + assignee="ana@alpha.example", assigned_by="ben@beta.example", + organization_id=ORG_B) + + listed = await pm_personal.my_inbox(user=ANA, page=page()) + assert [r["title"] for r in listed.rows] == [] diff --git a/tests/unit/test_tenancy_boundary.py b/tests/unit/test_tenancy_boundary.py index 4105249e..8ca06c23 100644 --- a/tests/unit/test_tenancy_boundary.py +++ b/tests/unit/test_tenancy_boundary.py @@ -49,9 +49,6 @@ #: Tables that carry a tenant key today. Not a baseline — the goal state. EXPECTED_SCOPED = { "app_user", - "crm_activities", - "crm_contacts", - "crm_deals", "org_group", "org_role", # WS-29a — the whole Projects app, keyed while it was empty. @@ -62,7 +59,9 @@ "pm_tasks", "pm_view_task_positions", "pm_views", } -#: ⚠️ FROZEN 2026-08-08 at 137, now 120 (WS-29a took the 17 `pm_*` out). +#: ⚠️ FROZEN 2026-08-08 at 137 → 123. WS-29a took the 17 `pm_*` out (-17); +#: the three `crm_*` homonyms came IN (+3) once this file started matching +#: the FK target, because they were never tenant-scoped at all. #: Every table predating the multi-tenant decision. #: Adding a name here is allowed and must come with a reason in the PR; adding #: one *silently* is how a 137 becomes a 160 without anybody choosing it. @@ -86,7 +85,11 @@ "chat_session_participant", # copilot_* "copilot_config", "copilot_event", -# crm_* +# crm_* — ⚠️ `crm_activities`, `crm_contacts` and `crm_deals` DO carry a column + # called `organization_id`, but it REFERENCES crm_organizations, a CUSTOMER + # COMPANY. They are unscoped like the rest of the family, and they are the + # reason this file matches the foreign key's target rather than its name. + "crm_activities", "crm_contacts", "crm_deals", "crm_deal_contacts", "crm_deal_statuses", "crm_lead_statuses", "crm_leads", "crm_lost_reasons", "crm_organizations", "crm_status_changes", "crm_sync_cursors", "crm_zoho_tombstones", @@ -166,6 +169,36 @@ "workflows",} +#: ``organization_id … REFERENCES organization`` — the TENANT, on the same line +#: or the next one. Whitespace-tolerant because the migrations column-align. +_TENANT_FK = re.compile( + r"\borganization_id\b[^,]*?REFERENCES\s+organization\s*\(", re.I | re.S +) + + +def _references_the_tenant(body: str) -> bool: + """Does this table body carry a tenant key — as opposed to a HOMONYM? + + ⚠️ **This function exists because the first version of this file was wrong, + and wrong in the direction that flatters.** It matched the column NAME, so + it counted `crm_activities`, `crm_contacts` and `crm_deals` as tenant-scoped + on the strength of an `organization_id` that `REFERENCES crm_organizations` + — a CUSTOMER COMPANY, not the tenant root. The published figure was six + scoped tables; it was three. + + A guard that can be satisfied by a coincidence of naming is not a guard: any + future table with an `organization_id` pointing anywhere at all would have + passed silently, which is precisely the failure this ratchet exists to + prevent. The foreign key's TARGET is the claim, so the target is what is + matched. + + **Consequence worth carrying forward: the column name is already taken in + `crm_*`.** Scoping the CRM needs a rename or a different name, and that has + to be decided before WS-29d touches those tables. + """ + return bool(_TENANT_FK.search(body)) + + def _scan() -> tuple[set[str], set[str]]: """Every table the migrations define, and which of them are tenant-scoped. @@ -193,10 +226,13 @@ def _scan() -> tuple[set[str], set[str]]: re.S, ): tables.add(match.group(1)) - if re.search(r"\borganization_id\b", match.group(2)): + if _references_the_tenant(match.group(2)): scoped.add(match.group(1)) for match in re.finditer(r"ALTER TABLE\s+([a-z_][a-z0-9_]*)(.*?);", src, re.S): - if re.search(r"ADD COLUMN[^;]*\borganization_id\b", match.group(2)): + body = match.group(2) + if re.search(r"ADD COLUMN[^;]*\borganization_id\b", body) and ( + _references_the_tenant(body) + ): scoped.add(match.group(1)) return tables, scoped @@ -266,4 +302,4 @@ def test_the_expected_scoped_set_is_real_not_aspirational() -> None: def test_the_frozen_count_matches_the_baseline() -> None: """The docstring quotes 120. A baseline whose stated size and real size disagree is a baseline nobody trusts.""" - assert len(BASELINE_UNSCOPED) == 120 + assert len(BASELINE_UNSCOPED) == 123 From 13e495827cb302a0e94849422ec30787f6d73675 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 21:40:55 +0000 Subject: [PATCH 14/22] fix(WS-29 S1-4): an agent acted as whoever ran last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LIVE cross-user bug, not a future multi-tenant one. Both executors did `if _mu: _set_memory_user_id(_mu); os.environ["ACB_AGENT_USER_EMAIL"] = _mu` — set CONDITIONALLY, never unset. The conditional is what made the never-unset lethal: a run whose payload named nobody kept whatever the last run left, and four agents/skills fed that straight into X-User-Email. Measured against the real run_agent before anything was touched: run 1 as alice, run 2 names nobody → run 2 acted as alice same, on the same asyncio task → the ContextVar leaked too alice and bob concurrently → alice's agent observed bob's address alice concurrent with an unattributed run → the unattributed run was alice Under D-MT-1 that email string IS the tenant, so this is also the cleanest cross-tenant path in the system: no database predicate touches it. TWO CORRECTIONS TO THE AUDIT, in both directions, which is why the agent was told to verify before fixing: OVERSTATED — the audit blamed WS-27f's dispatch at agent_dispatch.py:144 for passing a bare string past the guard. It never impersonated anybody: a string payload raises AttributeError at executor.py's list(payload.keys()) before anything runs, and the dispatch is recorded failed. Verified by execution. The live paths are the callers that pass a dict and no user — the workflow agent node and sub-agent dispatch. UNDERSTATED — the audit called the ContextVar correct. It was a bare .set() with no reset, and an awaited coroutine runs in its CALLER's context, so deleting the env var alone would have left the leak intact. The fix keeps the existing seam (acb_skills.memory_tools) and gives it a scope. Binding is UNCONDITIONAL including the empty string, so a run that cannot name its user has nobody to act as and its clients refuse — fail closed rather than inherit. A second ContextVar separates "an open scope" from "a value lying around", which is what still admits the one legitimate inheritance: a sub-agent dispatched with no user from inside a parent that bound one. Release is in the same `finally` as the other per-run teardown, and a reset raising in a foreign context clears rather than leaves standing. os.environ is gone from the identity path entirely. 17 tests driving the REAL executors and the real agent client. 13 of 15 failed on the pre-fix tree — reverted byte-identically to prove it, then restored — with assertions like "a run that named nobody acted as 'alice@fracktal.in'". Concurrency is asserted on what the CLIENT resolves, not on what was .set(), and gated by asyncio.Barrier so both runs are open at once. 7 mutants, 7 killed, reverts byte-identical. Two survived the first pass and were fixed rather than accepted: inheriting any non-empty ContextVar rather than only an open scope, and a reader reinstating the env fallback. Verified here, not taken on report: 1261 passed across the projects/tenancy/ agent/auth slice over two consecutive runs, ruff clean. An earlier run showed five failures that did not reproduce — the other wave agent's mutation harness was live-editing files mid-run, which is a real hazard of parallel agents and the reason for the second confirming run. CONSEQUENCE, deliberately left: workflow agent nodes now fail closed. They build a payload with no user and set actor to "workflow:", not an email, so their gateway-calling tools refuse where they previously acted as the last human. Threading started_by through is the correct close, but it touches the run lifecycle and belongs with "tenant on the event". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- apps/agents/agent-crm/agents.py | 27 +- apps/agents/agent-email-assistant/agents.py | 27 +- .../agents/agent-whatsapp-assistant/agents.py | 27 +- .../orchestrator/orchestrator/executor.py | 115 +++-- apps/skills/skill-task-gtd/SKILL.md | 6 +- .../skill-task-gtd/skill_task_gtd/core.py | 20 +- .../acb_skills/acb_skills/memory_tools.py | 74 +++ tests/unit/test_agent_gateway_identity.py | 7 +- tests/unit/test_agent_run_identity.py | 433 ++++++++++++++++++ tests/unit/test_crm_agent.py | 2 +- 10 files changed, 657 insertions(+), 81 deletions(-) create mode 100644 tests/unit/test_agent_run_identity.py diff --git a/apps/agents/agent-crm/agents.py b/apps/agents/agent-crm/agents.py index c25fdfdc..f7cf8852 100644 --- a/apps/agents/agent-crm/agents.py +++ b/apps/agents/agent-crm/agents.py @@ -72,19 +72,22 @@ def _gateway_url() -> str: def _current_user_email() -> str: - """The user the agent acts for. Primary source is the memory ContextVar the - executor sets; fall back to ACB_AGENT_USER_EMAIL (set by the gateway per run) - since the tool-callback context can drop ContextVars. Without either there is - nobody to act as, and :func:`_headers` refuses rather than calling the - gateway as the platform itself.""" + """The user the agent acts for: the per-run ContextVar the executor binds, + and nothing else. + + There was an ``ACB_AGENT_USER_EMAIL`` fallback here, justified by "the + tool-callback context can drop ContextVars". It was one slot in a shared + async process that no run ever cleared, so what it supplied to a run with no + identity was the LAST run's user — and to a concurrent run, whichever tenant + assigned it most recently. Under one-organization-per-user that email IS the + tenant. Resolving to ``""`` instead makes :func:`_headers` refuse, which is + the right answer rather than merely the safe one: a run nobody is attributed + to has nothing to do, not everything.""" try: from acb_skills.memory_tools import _get_memory_user_id - user = _get_memory_user_id() or "" - if user: - return user + return _get_memory_user_id() or "" except Exception: - pass - return os.environ.get("ACB_AGENT_USER_EMAIL", "") + return "" def _internal_token() -> str: @@ -126,8 +129,8 @@ def _headers() -> dict[str, str]: if not user: raise RuntimeError( "No acting user for this run, so there is nobody to act as — " - "refusing to call the gateway as the platform itself. The run " - "should set ACB_AGENT_USER_EMAIL." + "refusing to call the gateway as the platform itself. Dispatch " + "the run with user_email in its payload." ) return { "Authorization": f"Bearer {_internal_token()}", diff --git a/apps/agents/agent-email-assistant/agents.py b/apps/agents/agent-email-assistant/agents.py index 11cb237b..8e38916f 100644 --- a/apps/agents/agent-email-assistant/agents.py +++ b/apps/agents/agent-email-assistant/agents.py @@ -61,18 +61,23 @@ def _gateway_url() -> str: def _current_user_email() -> str: - """The user the agent is acting for. Primary source is the memory ContextVar - the executor sets; the Copilot SDK runs tool callbacks in a context that can - drop ContextVars, so fall back to ACB_AGENT_USER_EMAIL (set by the gateway - per run). Without either, gateway calls are unscoped.""" + """The user the agent is acting for: the per-run ContextVar the executor + binds, and nothing else. + + There was an ``ACB_AGENT_USER_EMAIL`` fallback here, justified by "the + Copilot SDK runs tool callbacks in a context that can drop ContextVars". It + was one slot in a shared async process that no run ever cleared, so what it + supplied to a run with no identity was the LAST run's user — and to a + concurrent run, whichever tenant assigned it most recently. Under + one-organization-per-user that email IS the tenant. Resolving to ``""`` + instead makes :func:`_headers` refuse, which is the right answer rather than + merely the safe one: a run nobody is attributed to has nothing to do, not + everything.""" try: from acb_skills.memory_tools import _get_memory_user_id # noqa: PLC0415 - user = _get_memory_user_id() or "" - if user: - return user + return _get_memory_user_id() or "" except Exception: # noqa: BLE001 - pass - return os.environ.get("ACB_AGENT_USER_EMAIL", "") + return "" def _internal_token() -> str: @@ -113,8 +118,8 @@ def _headers() -> dict[str, str]: if not user: raise RuntimeError( "No acting user for this run, so there is nobody to act as — " - "refusing to call the gateway as the platform itself. The run " - "should set ACB_AGENT_USER_EMAIL." + "refusing to call the gateway as the platform itself. Dispatch " + "the run with user_email in its payload." ) return { "Authorization": f"Bearer {_internal_token()}", diff --git a/apps/agents/agent-whatsapp-assistant/agents.py b/apps/agents/agent-whatsapp-assistant/agents.py index f959d8fd..74e1085a 100644 --- a/apps/agents/agent-whatsapp-assistant/agents.py +++ b/apps/agents/agent-whatsapp-assistant/agents.py @@ -50,19 +50,22 @@ def _gateway_url() -> str: def _current_user_email() -> str: - """The user the agent acts for. Primary source is the memory ContextVar the - executor sets; fall back to ACB_AGENT_USER_EMAIL (set by the gateway per run) - since the tool-callback context can drop ContextVars. Without either there is - nobody to act as, and :func:`_headers` refuses rather than calling the - gateway as the platform itself.""" + """The user the agent acts for: the per-run ContextVar the executor binds, + and nothing else. + + There was an ``ACB_AGENT_USER_EMAIL`` fallback here, justified by "the + tool-callback context can drop ContextVars". It was one slot in a shared + async process that no run ever cleared, so what it supplied to a run with no + identity was the LAST run's user — and to a concurrent run, whichever tenant + assigned it most recently. Under one-organization-per-user that email IS the + tenant. Resolving to ``""`` instead makes :func:`_headers` refuse, which is + the right answer rather than merely the safe one: a run nobody is attributed + to has nothing to do, not everything.""" try: from acb_skills.memory_tools import _get_memory_user_id - user = _get_memory_user_id() or "" - if user: - return user + return _get_memory_user_id() or "" except Exception: - pass - return os.environ.get("ACB_AGENT_USER_EMAIL", "") + return "" def _internal_token() -> str: @@ -103,8 +106,8 @@ def _headers() -> dict[str, str]: if not user: raise RuntimeError( "No acting user for this run, so there is nobody to act as — " - "refusing to call the gateway as the platform itself. The run " - "should set ACB_AGENT_USER_EMAIL." + "refusing to call the gateway as the platform itself. Dispatch " + "the run with user_email in its payload." ) return { "Authorization": f"Bearer {_internal_token()}", diff --git a/apps/services/orchestrator/orchestrator/executor.py b/apps/services/orchestrator/orchestrator/executor.py index ad52e971..a5859f16 100644 --- a/apps/services/orchestrator/orchestrator/executor.py +++ b/apps/services/orchestrator/orchestrator/executor.py @@ -1680,6 +1680,52 @@ async def _integration_authorizer(event_payload: Any, thread_id: str | None = No return None +def _payload_user(event_payload: Any) -> str: + """The acting user this payload names, or ``""`` when it names nobody. + + ``""`` is a real answer, not a missing one — see :func:`_bind_run_identity`. + """ + if not isinstance(event_payload, dict): + return "" + return str( + event_payload.get("user_email") or event_payload.get("user_id") or "" + ) + + +def _bind_run_identity(event_payload: Any, agent_name: str = "") -> Any: + """Open this run's acting-user scope; hand back what closes it. + + One helper for both executors so the two paths cannot drift — they did + before, and the drift is invisible until somebody reads the other one. + """ + try: + from acb_skills.memory_tools import ( + _bind_memory_user_id, + _get_memory_user_id, + ) + binding = _bind_memory_user_id(_payload_user(event_payload)) + if not _get_memory_user_id(): + # Not an error: a platform run (cron, reconciler, an event with no + # person behind it) legitimately has nobody. Logged because the + # consequence is otherwise invisible — the gateway-calling tools + # will refuse, and "the agent said it had nobody to act as" has to + # be answerable without a debugger. It used to be answerable the + # wrong way, by acting as whoever ran last. + _log.info("executor.run_has_no_acting_user", agent=agent_name) + return binding + except Exception: + return None + + +def _unbind_run_identity(binding: Any) -> None: + """Close a :func:`_bind_run_identity` scope. Never raises.""" + try: + from acb_skills.memory_tools import _unbind_memory_user_id + _unbind_memory_user_id(binding) + except Exception: + pass + + async def run_agent( agent_name: str, event_payload: dict[str, Any], @@ -1690,6 +1736,12 @@ async def run_agent( ) -> dict[str, Any]: """Dynamically load and execute a named agent. + Thin wrapper over :func:`_run_agent_inner` whose only job is to open and — + crucially — CLOSE this run's acting-user scope, so the identity cannot + outlive the run on a caller's task. A wrapper rather than a ``try/finally`` + around the body because the body is three hundred lines and its own + ``except`` re-raises; the boundary belongs where it is impossible to miss. + Args: agent_name: Bare agent name, e.g. ``"task-manager"``. event_payload: Arbitrary event data injected as the initial state. @@ -1701,25 +1753,31 @@ async def run_agent( Raises: :class:`AgentRunError` on failure (includes mutation PR URL if one was opened). """ + _identity = _bind_run_identity(event_payload, agent_name) + try: + return await _run_agent_inner( + agent_name, event_payload, + run_id=run_id, thread_id=thread_id, model=model, + ) + finally: + _unbind_run_identity(_identity) + + +async def _run_agent_inner( + agent_name: str, + event_payload: dict[str, Any], + *, + run_id: str | None = None, + thread_id: str | None = None, + model: str | None = None, +) -> dict[str, Any]: + """The batch run itself. Call :func:`run_agent`, not this — this one assumes + the acting-user scope is already open.""" _disable_agent_telemetry_once() settings = get_settings() run_id = run_id or str(uuid.uuid4()) thread_id = thread_id or f"{agent_name}:{run_id}" - # Set the memory/user ContextVar from the payload so user-scoped tools and - # memory resolve the acting user (mirrors run_agent_stream). - try: - from acb_skills.memory_tools import _set_memory_user_id - _mu = str( - event_payload.get("user_email") - or event_payload.get("user_id") or "" - ) if isinstance(event_payload, dict) else "" - if _mu: - _set_memory_user_id(_mu) - os.environ["ACB_AGENT_USER_EMAIL"] = _mu - except Exception: - pass - record( AuditEvent( actor="system:gateway", @@ -2172,25 +2230,13 @@ async def run_agent_stream( settings = get_settings() # ── User context for tools/memory ────────────────────────────────────── - # Set the memory ContextVar HERE (inside the generator, before any agent - # task spawns) from the payload, so user-scoped tools and memory see the - # acting user. Setting it in the calling route doesn't survive into the - # streaming/agent execution context. - try: - from acb_skills.memory_tools import _set_memory_user_id - _mu = "" - if isinstance(event_payload, dict): - _mu = str( - event_payload.get("user_email") - or event_payload.get("user_id") or "" - ) - if _mu: - _set_memory_user_id(_mu) - # Fallback for tool callbacks the Copilot SDK runs outside this - # ContextVar's reach (single-user deployments). - os.environ["ACB_AGENT_USER_EMAIL"] = _mu - except Exception: - pass + # Bind the acting user HERE (inside the generator, before any agent task + # spawns) from the payload, so user-scoped tools and memory see it: setting + # it in the calling route doesn't survive into the streaming/agent execution + # context. Released in this generator's finally — an identity that outlives + # its run is the next run's identity, which is the bug this shape exists to + # prevent (see _bind_memory_user_id). + _identity_binding = _bind_run_identity(event_payload, agent_name) # ── Run correlation (E2 observability) ───────────────────────────────── # Bind run_id/thread_id/agent/user into structlog contextvars so EVERY log @@ -4048,6 +4094,9 @@ async def _run_task() -> str: pass _stream_relay_thread_id.reset(_relay_token) _active_run_model.reset(_model_token) + # Same reason, for the thing that says WHO this run was: an acting user + # left bound is inherited by whatever runs next on this task (S1-4). + _unbind_run_identity(_identity_binding) # B6 Phase-5 Tier 0: tear down this run's scoped integration creds so # they don't linger in the shared process env for the next agent. _restore_integration_env(_integration_env_token) diff --git a/apps/skills/skill-task-gtd/SKILL.md b/apps/skills/skill-task-gtd/SKILL.md index d891b01c..5d9efdfb 100644 --- a/apps/skills/skill-task-gtd/SKILL.md +++ b/apps/skills/skill-task-gtd/SKILL.md @@ -22,5 +22,7 @@ connected workspace stages the item (`sync_state='pending'`); the **user** pushes it from the UI. The Action Broker takes over gating in Phase 4. Env: `GATEWAY_URL` (default `http://localhost:8080`), internal token via -settings/`LITELLM_MASTER_KEY`; acting user via ContextVar or -`ACB_AGENT_USER_EMAIL`. +settings/`LITELLM_MASTER_KEY`. The acting user comes from the per-run +ContextVar the executor binds from the run payload's `user_email`, and from +nowhere else — the old `ACB_AGENT_USER_EMAIL` env fallback was a process-global +that no run cleared, so it handed an unattributed run the previous user. diff --git a/apps/skills/skill-task-gtd/skill_task_gtd/core.py b/apps/skills/skill-task-gtd/skill_task_gtd/core.py index 2990b5ba..98accab5 100644 --- a/apps/skills/skill-task-gtd/skill_task_gtd/core.py +++ b/apps/skills/skill-task-gtd/skill_task_gtd/core.py @@ -72,16 +72,18 @@ def _gateway_url() -> str: def _current_user_email() -> str: - """The user this agent run acts for (ContextVar first, env fallback — - the exact recipe agent-email-assistant uses).""" + """The user this run acts for: the per-run ContextVar the executor binds, + and nothing else — the exact recipe agent-email-assistant uses. + + The ``ACB_AGENT_USER_EMAIL`` fallback that used to sit here was one slot in + a shared async process that no run ever cleared, so it handed a run with no + identity the LAST run's user. Resolving to ``""`` makes :func:`_headers` + refuse instead.""" try: from acb_skills.memory_tools import _get_memory_user_id - user = _get_memory_user_id() or "" - if user: - return user + return _get_memory_user_id() or "" except Exception: - pass - return os.environ.get("ACB_AGENT_USER_EMAIL", "") + return "" def _internal_token() -> str: @@ -117,8 +119,8 @@ def _headers() -> dict[str, str]: if not user: raise RuntimeError( "No acting user for this run, so there is nobody to act as — " - "refusing to call the gateway as the platform itself. The run " - "should set ACB_AGENT_USER_EMAIL." + "refusing to call the gateway as the platform itself. Dispatch " + "the run with user_email in its payload." ) return { "Authorization": f"Bearer {_internal_token()}", diff --git a/packages/acb_skills/acb_skills/memory_tools.py b/packages/acb_skills/acb_skills/memory_tools.py index 843728d5..4873b48f 100644 --- a/packages/acb_skills/acb_skills/memory_tools.py +++ b/packages/acb_skills/acb_skills/memory_tools.py @@ -56,14 +56,34 @@ ) +# True once somebody on THIS context has deliberately named the acting user — +# a request handler resolving it from the session, or a run binding it from its +# payload. It is what separates "this run legitimately inherits the identity its +# caller resolved" (a sub-agent inside a parent run) from "this run found a +# leftover identity lying around" (a scheduler, a workflow node, the next run on +# a reused task). The second must NOT inherit: see :func:`_bind_memory_user_id`. +_memory_user_named: contextvars.ContextVar[bool] = contextvars.ContextVar( + "_memory_user_named", default=False +) + +#: What :func:`_bind_memory_user_id` hands back to :func:`_unbind_memory_user_id`. +MemoryUserBinding = tuple["contextvars.Token[str]", "contextvars.Token[bool]"] + + def _set_memory_user_id(user_id: str) -> None: """Set the current user ID for memory tool operations. Called by the gateway route handler before dispatching an agent run. The memory tools (remember, save_memory, save_episode) read this context var to determine whose memory to operate on. + + A non-empty *user_id* also marks this context as having deliberately named + its acting user, so a nested run whose payload names nobody inherits it + rather than refusing. An empty one names nobody and marks nothing. """ _memory_user_id.set(user_id or "") + if user_id: + _memory_user_named.set(True) def _get_memory_user_id() -> str: @@ -71,6 +91,60 @@ def _get_memory_user_id() -> str: return _memory_user_id.get() +def _bind_memory_user_id(user_id: str) -> MemoryUserBinding: + """Bind the acting user for exactly one agent run; return a reset token. + + Unlike :func:`_set_memory_user_id` this is **unconditional**, and that is + the whole point. The previous shape was:: + + if _mu: + _set_memory_user_id(_mu) + os.environ["ACB_AGENT_USER_EMAIL"] = _mu # never cleared + + — so a run whose payload named nobody kept whatever the last run left, in a + ContextVar the caller's task still held and in a process-global env var every + concurrent run shared. An agent then called the gateway with somebody else's + address in ``X-User-Email``. Binding the empty string instead means a run + that cannot name its user has nobody to act as, and the tool clients refuse. + + The one inheritance that IS legitimate is a sub-agent: ``call_agent`` and the + sub-agent batch path dispatch ``{"message": ..., "mode": "sub_task"}`` with no + user, from inside a parent run that already bound one on this same context. + That case is admitted by :data:`_memory_user_named` — which is set by the + binding and reset with it, so it is true only while a real enclosing scope is + open, never because a previous run finished and left it behind. + + Pair every call with :func:`_unbind_memory_user_id` in a ``finally``. + """ + resolved = user_id or "" + if not resolved and _memory_user_named.get(): + # Inside a scope that named its user: inherit it (sub-agent delegation). + resolved = _memory_user_id.get() + return (_memory_user_id.set(resolved), _memory_user_named.set(bool(resolved))) + + +def _unbind_memory_user_id(binding: MemoryUserBinding | None) -> None: + """Release a :func:`_bind_memory_user_id` scope. Never raises. + + ``Token.reset`` demands the context it was created in, and the streaming + executor's teardown does not always run in it — MAF's own telemetry hook hit + exactly that (see ``executor._disable_agent_telemetry_once``). A failed reset + must not leave the identity standing for the next run, so the fallback is to + clear the binding outright, which fails closed rather than open. + """ + if binding is None: + return + user_token, named_token = binding + try: + _memory_user_id.reset(user_token) + except (ValueError, RuntimeError): + _memory_user_id.set("") + try: + _memory_user_named.reset(named_token) + except (ValueError, RuntimeError): + _memory_user_named.set(False) + + def _set_memory_agent_name(agent_name: str) -> None: """Set the current agent name for agent-scoped memory operations. diff --git a/tests/unit/test_agent_gateway_identity.py b/tests/unit/test_agent_gateway_identity.py index c9e5068c..69a9660f 100644 --- a/tests/unit/test_agent_gateway_identity.py +++ b/tests/unit/test_agent_gateway_identity.py @@ -66,7 +66,12 @@ def test_a_run_with_nobody_to_act_as_refuses_rather_than_acting_as_the_platform( with pytest.raises(RuntimeError) as exc: mod._headers() # The message is relayed to the agent verbatim, so it has to say what to do. - assert "ACB_AGENT_USER_EMAIL" in str(exc.value) + # It names the run payload, not ACB_AGENT_USER_EMAIL: that env var WAS the + # remedy the message advertised, and it was also S1-4 — one process-global + # slot no run cleared, so following the advice handed the next unattributed + # run this user. See tests/unit/test_agent_run_identity.py. + assert "nobody to act as" in str(exc.value) + assert "user_email" in str(exc.value) @pytest.mark.parametrize(("label", "path"), CLIENTS, ids=[c[0] for c in CLIENTS]) diff --git a/tests/unit/test_agent_run_identity.py b/tests/unit/test_agent_run_identity.py new file mode 100644 index 00000000..409e6800 --- /dev/null +++ b/tests/unit/test_agent_run_identity.py @@ -0,0 +1,433 @@ +"""S1-4 — an agent run's acting user must not outlive the run, or cross into another. + +Spec: ``ai-company-brain/specs/multi_tenancy_leak_audit.md`` §S1-4. + +Both executors used to open every run with:: + + if _mu: + _set_memory_user_id(_mu) + os.environ["ACB_AGENT_USER_EMAIL"] = _mu # never cleared + +and the four tool clients that call the gateway on an agent's behalf +(email-assistant, crm, whatsapp-assistant, skill-task-gtd) each read that env +var as their fallback answer to "who am I acting for". Two things follow, and +this file measured both against the real ``run_agent`` before fixing them: + +* A run whose payload names nobody — a workflow agent node + (``routes/workflows/service.py:118-129``), a sub-agent batch dispatch — took + the LAST run's user, because ``if _mu:`` skipped the assignment and the slot + still held it. Measured: ``env='alice@fracktal.in'`` inside a second run, + in a fresh event loop, dispatched by nobody. +* Two runs in flight at once shared the one slot, so the loser of the race read + the winner's user. Measured: alice's run observing bob's address in ``env`` + while its own ContextVar still correctly said alice. + +The ContextVar leaked too, which the audit did not claim: ``_set_memory_user_id`` +is a bare ``.set()`` with no reset, and an awaited coroutine runs in its +CALLER's context — so two sequential ``await run_agent(...)`` calls on one task +(exactly what a request handler or a workflow does) left run 1's identity +standing for run 2. Measured: ``ctxvar='alice@fracktal.in'`` in a run whose +payload named nobody. + +Under D-MT-1 that email is the tenant, so each of these is a cross-tenant read. + +What is pinned here: identity is bound per run and released with it, an +unattributed run resolves to nobody and its tool clients refuse, concurrent runs +cannot see each other — and the one inheritance that IS legitimate, a sub-agent +delegating inside its parent's run, still works. +""" +from __future__ import annotations + +import asyncio +import importlib.util +import os +import sys +from pathlib import Path +from typing import Any + +import pytest +from acb_skills.memory_tools import _get_memory_user_id +from orchestrator import executor + +REPO = Path(__file__).resolve().parents[2] + +ALICE = "alice@fracktal.in" +BOB = "bob@othertenant.example" + +# The streaming path's Tier-1→Tier-2 fallback discards one un-awaited ``run()`` +# coroutine from the probe (it does not implement native streaming) — the same +# benign warning ``test_run_agent_stream_e2e`` filters, for the same reason. +pytestmark = pytest.mark.filterwarnings( + "ignore:coroutine .*run.* was never awaited:RuntimeWarning" +) + + +# ── A probe agent: it reports who the tool surface thinks it is ────────────── + +class _Resp: + def __init__(self, text: str) -> None: + self.text = text + self.messages: list[Any] = [] + + +class _ProbeAgent: + """A minimal MAF-shaped agent that records the identity visible to tools. + + ``run`` is where a real agent's tool callbacks fire, so reading the identity + here reads it exactly where a gateway call would. + """ + + def __init__(self, seen: list[dict[str, Any]], gate: Any = None) -> None: + self.name = "identity-probe" + self.tools: list[Any] = [] + self.default_options: dict[str, Any] = {} + self._seen = seen + self._gate = gate + + async def run(self, *_a: Any, **_k: Any) -> _Resp: + if self._gate is not None: + await self._gate() # hold both runs open at once + self._seen.append({ + "ctxvar": _get_memory_user_id(), + "env": os.environ.get("ACB_AGENT_USER_EMAIL", ""), + "client": _crm_client_identity(), + }) + return _Resp("ok") + + async def __aenter__(self) -> _ProbeAgent: + return self + + async def __aexit__(self, *_a: Any) -> bool: + return False + + +class _Loaded: + def __init__(self, seen: list[dict[str, Any]], gate: Any) -> None: + self.agent_dir = Path("/tmp") + self.agent_name = "identity-probe" + self.config: dict[str, Any] = {} + self._seen = seen + self._gate = gate + + def build_agents(self) -> list[Any]: + return [_ProbeAgent(self._seen, self._gate)] + + +class _LoadCtx: + def __init__(self, seen: list[dict[str, Any]], gate: Any = None) -> None: + self._seen = seen + self._gate = gate + + def __enter__(self) -> _Loaded: + return _Loaded(self._seen, self._gate) + + def __exit__(self, *_a: Any) -> bool: + return False + + +# ── The real reader, loaded from the agent package it lives in ────────────── + +def _crm_client() -> Any: + """``agent-crm``'s gateway client — one of the four real readers. + + Loaded by path (it is not on the import path) and cached, mirroring + ``test_agent_gateway_identity``. Driving the REAL reader is the point: a + test that re-implements ``_current_user_email`` would have passed + throughout the bug. + """ + mod = sys.modules.get("_identity_probe_crm") + if mod is not None: + return mod + path = REPO / "apps/agents/agent-crm/agents.py" + spec = importlib.util.spec_from_file_location("_identity_probe_crm", path) + if spec is None or spec.loader is None: # pragma: no cover - import plumbing + pytest.skip(f"cannot load {path}") + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + try: + spec.loader.exec_module(mod) + except Exception as exc: # pragma: no cover - optional agent deps absent + pytest.skip(f"agent-crm: {exc}") + return mod + + +def _crm_client_identity() -> str: + return str(_crm_client()._current_user_email()) + + +# ── Driving the real executors ────────────────────────────────────────────── + +@pytest.fixture +def probe(monkeypatch: pytest.MonkeyPatch): + """Both executors, wired to the probe agent. No clone, no LLM, no audit row.""" + seen: list[dict[str, Any]] = [] + gate: dict[str, Any] = {"fn": None} + monkeypatch.setattr( + executor, "load_agent", lambda *a, **k: _LoadCtx(seen, gate["fn"]) + ) + monkeypatch.setattr(executor, "build_integrations", lambda *a, **k: ({}, {})) + monkeypatch.setattr(executor, "record", lambda *a, **k: None) + # The env var is the leak under test; never inherit one from the shell or + # from another test, and never leave one behind. + monkeypatch.delenv("ACB_AGENT_USER_EMAIL", raising=False) + return type("Probe", (), {"seen": seen, "gate": gate})() + + +async def _run(payload: dict[str, Any]) -> Any: + return await executor.run_agent("identity-probe", payload) + + +async def _drain_stream(payload: dict[str, Any]) -> None: + async for _ in executor.run_agent_stream("identity-probe", payload): + pass + + +# ── 1. The leak, in the shape it actually shipped ─────────────────────────── + +def test_a_later_run_that_names_nobody_does_not_inherit_the_earlier_user(probe): + """The env-var leak, isolated: two runs, two event loops, two contexts. + + Nothing but a process-global could carry alice across this boundary — the + second run's ContextVar is a fresh default. Before the fix the second run's + tool client answered ``alice@fracktal.in``. + """ + asyncio.run(_run({"user_email": ALICE})) + asyncio.run(_run({"message": "who am I?", "mode": "sub_task"})) + + first, second = probe.seen + assert first["client"] == ALICE + assert second["client"] == "", ( + f"a run that named nobody acted as {second['client']!r}" + ) + assert second["env"] == "" + + +def test_a_later_run_on_the_same_task_does_not_inherit_either(probe): + """The ContextVar leak the audit did not claim. + + Sequential ``await``s share one context, and the old bind was a ``.set()`` + with no reset — so run 2 saw run 1's identity in the ContextVar itself, not + only in the env var. This is the shape of a request handler or a workflow + running two agents in a row. + """ + async def _both() -> None: + await _run({"user_email": ALICE}) + await _run({"message": "who am I?", "mode": "sub_task"}) + + asyncio.run(_both()) + + _first, second = probe.seen + assert second["ctxvar"] == "", ( + f"the acting user survived its run: {second['ctxvar']!r}" + ) + assert second["client"] == "" + + +def test_the_identity_is_released_when_the_run_ends(probe): + """The caller's own context is left as the run found it. + + Otherwise the leak simply moves up one frame: the route that awaited the run + now carries the identity into whatever it does next. + """ + async def _scenario() -> str: + await _run({"user_email": ALICE}) + return _get_memory_user_id() + + assert asyncio.run(_scenario()) == "" + + +# ── 2. Concurrency: the ContextVar has to be read from the right context ──── + +def test_two_interleaved_runs_never_see_each_other_s_user(probe): + """Both runs are held open simultaneously, then read their identity. + + A ContextVar that is set but read from the wrong context is the same bug in + a better type, so this asserts on what the tool client resolves — the value + that would land in ``X-User-Email``. + """ + async def _scenario() -> None: + barrier = asyncio.Barrier(2) + + async def _gate() -> None: + await barrier.wait() + + probe.gate["fn"] = _gate + await asyncio.gather( + _run({"user_email": ALICE}), + _run({"user_email": BOB}), + ) + + asyncio.run(_scenario()) + + identities = sorted(row["client"] for row in probe.seen) + assert identities == sorted([ALICE, BOB]), ( + f"concurrent runs resolved {identities} — one read the other's tenant" + ) + # And nothing process-global was written for either of them. + assert {row["env"] for row in probe.seen} == {""} + + +def test_a_concurrent_run_cannot_lend_its_user_to_an_unattributed_one(probe): + """The cross-tenant read in its worst form: alice runs, bob's run names + nobody, and bob's agent must not reach alice's mailbox.""" + async def _scenario() -> None: + barrier = asyncio.Barrier(2) + + async def _gate() -> None: + await barrier.wait() + + probe.gate["fn"] = _gate + await asyncio.gather( + _run({"user_email": ALICE}), + _run({"message": "no user here"}), + ) + + asyncio.run(_scenario()) + + assert sorted(row["client"] for row in probe.seen) == ["", ALICE] + + +# ── 3. Fail closed, at the surface that would have made the call ──────────── + +def test_a_stray_env_var_cannot_supply_an_identity( + probe, monkeypatch: pytest.MonkeyPatch +): + """The runtime half of the source fence: the env var is present and wrong. + + This is the operator-set case and the leftover-from-a-crashed-process case + at once. Nothing may consult it — a value in the environment is not evidence + that this run belongs to that person. + """ + monkeypatch.setenv("ACB_AGENT_USER_EMAIL", BOB) + asyncio.run(_run({"message": "dispatched by nobody"})) + + assert probe.seen[0]["client"] == "", ( + f"a process-global supplied {probe.seen[0]['client']!r} to a run " + "that named nobody" + ) + + +def test_an_identity_left_in_the_contextvar_is_not_inherited_either(probe): + """The rule is "a scope is OPEN", not "the variable is non-empty". + + The distinction is the whole fix: a value sitting in the ContextVar with no + run or request scope around it is exactly the leftover shape the env var + had, and reading it would move the bug rather than close it. Driven at the + seam because no executor can produce this state once the scopes are + balanced — which is the point of asserting it. + """ + from acb_skills.memory_tools import ( + _bind_memory_user_id, + _memory_user_id, + _unbind_memory_user_id, + ) + + stale = _memory_user_id.set(ALICE) # nobody's scope; just a value + try: + binding = _bind_memory_user_id("") + try: + assert _get_memory_user_id() == "" + finally: + _unbind_memory_user_id(binding) + finally: + _memory_user_id.reset(stale) + + +def test_an_unattributed_run_refuses_to_call_the_gateway(probe): + """End to end: executor binds nobody → the client raises rather than + sending a bearer with no identity, which the gateway reads as SERVICE_ACCESS.""" + asyncio.run(_run({"message": "dispatched by a workflow node"})) + + with pytest.raises(RuntimeError) as exc: + _crm_client()._headers() + assert "nobody to act as" in str(exc.value) + + +# ── 4. The inheritance that IS legitimate ─────────────────────────────────── + +def test_a_sub_task_inside_a_parent_run_still_acts_as_the_parent_s_user(probe): + """``call_agent`` and the sub-agent batch path dispatch + ``{"message": ..., "mode": "sub_task"}`` with no user, from inside a run that + already has one. That is delegation, not inheritance-by-accident, and it has + to keep working — otherwise the fix silently breaks every delegated run. + """ + async def _scenario() -> None: + from acb_skills.memory_tools import _bind_memory_user_id + binding = _bind_memory_user_id(ALICE) # stands in for the parent run + try: + await _run({"message": "sub-task", "mode": "sub_task"}) + finally: + from acb_skills.memory_tools import _unbind_memory_user_id + _unbind_memory_user_id(binding) + + asyncio.run(_scenario()) + assert probe.seen[0]["client"] == ALICE + + +def test_a_route_that_resolved_its_user_is_honoured_by_a_payload_that_did_not( + probe, +): + """``routes/agent.py`` resolves the acting user (or a room's write scope) and + calls ``_set_memory_user_id`` before dispatching; the payload does not always + repeat it. That naming is deliberate and in scope, so it is inherited — the + distinction the fix draws is between a scope that is OPEN and a value that was + merely left behind.""" + async def _scenario() -> None: + from acb_skills.memory_tools import _set_memory_user_id + _set_memory_user_id(ALICE) + await _run({"message": "payload without a user"}) + + asyncio.run(_scenario()) + assert probe.seen[0]["client"] == ALICE + + +# ── 5. The streaming executor gets the same treatment ─────────────────────── + +def test_the_streaming_run_also_releases_its_identity(probe): + """``run_agent_stream`` carried the identical pair of lines at :2191. Its + teardown resets the binding in the same ``finally`` that already restores the + relay token and this run's integration credentials.""" + async def _scenario() -> str: + await _drain_stream({"user_email": ALICE, "message": "hi"}) + return _get_memory_user_id() + + leftover = asyncio.run(_scenario()) + assert leftover == "" + assert os.environ.get("ACB_AGENT_USER_EMAIL", "") == "" + + +def test_a_streamed_run_that_names_nobody_starts_with_nobody(probe): + async def _scenario() -> None: + await _drain_stream({"user_email": ALICE, "message": "hi"}) + await _drain_stream({"message": "hi"}) + + asyncio.run(_scenario()) + assert [row["client"] for row in probe.seen] == [ALICE, ""] + + +# ── 6. Source fences: the env var must not come back ──────────────────────── + +#: Every file that wrote or read the process-global identity. +FORMER_ENV_USERS = [ + "apps/services/orchestrator/orchestrator/executor.py", + "apps/agents/agent-email-assistant/agents.py", + "apps/agents/agent-crm/agents.py", + "apps/agents/agent-whatsapp-assistant/agents.py", + "apps/skills/skill-task-gtd/skill_task_gtd/core.py", +] + + +@pytest.mark.parametrize("rel", FORMER_ENV_USERS) +def test_nothing_writes_or_reads_the_process_global_identity(rel: str) -> None: + """Asserted against the source because the runtime failure is silent: a + reinstated fallback looks like a working run right up until it is somebody + else's data. The name may still appear in prose explaining why it is gone.""" + src = (REPO / rel).read_text(encoding="utf-8") + code = "\n".join( + line for line in src.splitlines() + if "ACB_AGENT_USER_EMAIL" in line and not line.lstrip().startswith("#") + ) + for line in code.splitlines(): + assert "os.environ" not in line and "getenv" not in line, ( + f"{rel} reintroduced the process-global identity: {line.strip()}" + ) diff --git a/tests/unit/test_crm_agent.py b/tests/unit/test_crm_agent.py index dd08774b..11257b95 100644 --- a/tests/unit/test_crm_agent.py +++ b/tests/unit/test_crm_agent.py @@ -268,7 +268,7 @@ async def test_a_run_with_nobody_to_act_as_refuses_rather_than_calling( calls = _fake_gateway(monkeypatch, _responder, user="") with pytest.raises(RuntimeError) as exc: await invoke() - assert "ACB_AGENT_USER_EMAIL" in str(exc.value) + assert "nobody to act as" in str(exc.value) assert calls == [], "the gateway was called despite having nobody to act as" From cc520b115a7d7c06df136960d26f1e2205bfe254 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 21:42:36 +0000 Subject: [PATCH 15/22] fix(WS-29 S1-1): the admin plane resolved its tenant from a hard-coded slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_org_id(db) did `WHERE slug = 'default'` and ignored the caller, across 27 call sites — members, roles, groups, permission overrides, access requests, /auth/me. A tenant-B admin listed, invited into and granted roles in tenant `default`: a cross-tenant WRITE into access control, by a correctly-authorised caller, which is the worst shape of leak in the system because it grants further access. Fixed in three layers, because fixing only the first leaves the bug intact: 1. get_org_id(db, user) resolves the CALLER's tenant, reusing WS-29b's resolve_organization_id rather than deriving a second answer to "which tenant is this". DEFAULT_ORG_SLUG is DELETED, not demoted to a fallback — a fallback here re-creates the bug — and the comment in its place records that the slug now survives only in provisioning paths unreachable from a request. 2. find_member/get_member take the org and predicate on it. Every member-targeted route reaches its subject BY ADDRESS, so a caller-derived org id in front of an unscoped lookup is the same cross-tenant write with an extra query in front of it. 3. the provisioning upsert grew a tenant fence, and its SET became a COALESCE. app_user.email is UNIQUE, so inviting another tenant's address conflicts with THEIR row — and the old arm moved that person into the inviter's organization, with set_roles (which replaces assignments wholesale) next in the same transaction. WHAT THE LIVE RUN FOUND, invisible to every hermetic test and to me: app_user_email_key is UNIQUE (email) — BYTE-EXACT — while every lookup in this codebase matches lower(email) (R10). So a row stored as Casey@Alpha.Example does not conflict with the lower-cased address the inviter inserts: the fence never fires because there is no conflict, and Postgres writes a SECOND app_user row for the same human in the other organization. I reproduced it directly — two rows, two organizations, one person. Under D-MT-1(a) that makes resolve_organization_id return whichever row the planner hands back, so a person's tenant becomes non-deterministic. ⚠️ This weakens a claim I wrote into multi_tenancy.md §1.1 and built D-MT-1 on: that one-person-one-organization holds STRUCTURALLY because email is unique. It holds only for identically-spelled addresses. The decision stands — it is still the reversible direction — but its enforcement was application-level, not structural, and the spec said otherwise. Corrected there, and the migration that makes it structural follows. Closed in application code meanwhile (the one deliberately cross-tenant read, answering 404 like every other miss so it is not an existence oracle), and the fakes now model the index byte-exactly so it is reproducible hermetically. 15 mutants, no survivors, reverts byte-identical. M9 survived the first round — the fake reads the clause out of the statement and cannot evaluate SQL, so `OR TRUE` left the substring it looks for exactly where it was; killed with a structural assertion that the roster carries no disjunction. M6 and M7 are killed only structurally, which is defence in depth working: with the other guards intact they are not behaviourally exploitable. The frontend needed no change, and the agent checked rather than assumed: lib/access.ts already types organization as optional and nothing derives an org client-side. The single-org assumption was entirely server-side. Verified here: 1508 passed across the projects/tenancy/auth/people/admin/ agent slice, ruff clean on the changed files. LEFT AS A DECISION, not debt: the sign-in queue is genuinely shared. access_request has no tenant column and cannot straightforwardly have one — an address knocking at the door has no organization yet. Admin B can see and DENY admin A's pending knock: a cross-tenant DoS on onboarding. Approve is now fenced; deny cannot be without a routing rule (domain? invite token?). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/gateway/routes/admin/_common.py | 225 +++++- .../gateway/routes/admin/access_requests.py | 4 +- .../gateway/gateway/routes/admin/groups.py | 16 +- .../gateway/gateway/routes/admin/me.py | 8 +- .../gateway/gateway/routes/admin/members.py | 31 +- .../gateway/gateway/routes/admin/roles.py | 8 +- tests/unit/_admin_fakes.py | 211 +++++- tests/unit/test_admin_groups.py | 42 +- tests/unit/test_admin_member_offboarding.py | 46 +- tests/unit/test_admin_tenancy.py | 645 ++++++++++++++++++ 10 files changed, 1161 insertions(+), 75 deletions(-) create mode 100644 tests/unit/test_admin_tenancy.py diff --git a/apps/services/gateway/gateway/routes/admin/_common.py b/apps/services/gateway/gateway/routes/admin/_common.py index b589c484..d0e79d31 100644 --- a/apps/services/gateway/gateway/routes/admin/_common.py +++ b/apps/services/gateway/gateway/routes/admin/_common.py @@ -50,20 +50,47 @@ # names are re-exported rather than imported at each call site. from gateway.db import get_db # noqa: F401 from gateway.db import get_session_factory as _get_session_factory # noqa: F401 + +# The ONE answer to "which tenant is this caller" (WS-29b, D-MT-1 (a)). Imported +# rather than re-derived: two implementations of that question is exactly how +# they drift, and the one that drifts is the one nobody re-reads. +# `routes/projects/core.py` owns it because Projects needed it first; the +# question it answers belongs to no package. +from gateway.routes.projects.core import NO_ORGANIZATION, resolve_organization_id from sqlalchemy import text _log = get_logger("gateway.admin") router = APIRouter(prefix="/admin", tags=["admin"]) -#: Slug of the single organization this deployment serves. The column exists on -#: every table so a second org is a data change; resolving it through one -#: constant keeps that future honest without shipping an org switcher today. -DEFAULT_ORG_SLUG = "default" - #: Never assignable to a person — it is the internal service principal. NON_ASSIGNABLE_ROLES = frozenset({"agent_service"}) +#: ⚠️ **There is deliberately no ``DEFAULT_ORG_SLUG`` here any more.** +#: +#: It used to be the whole of this package's tenant model: ``get_org_id`` read +#: ``WHERE slug = 'default'`` and never consulted the caller, so every admin +#: read and every admin WRITE — invite, role grant, group membership, permission +#: override — landed in the `default` organization no matter who asked +#: (``multi_tenancy_leak_audit.md`` S1-1). A caller the permission system had +#: correctly authorised for *their own* org was silently redirected into +#: somebody else's access control. +#: +#: It is not kept as a fallback, because a fallback IS the bug: the day the +#: caller lookup returns nothing is exactly the day the slug would hand them +#: `default` again. Absence fails closed here (403), which is the whole point. +#: +#: The slug survives in precisely two places, both **provisioning, never +#: resolution**, and both outside the request path: +#: +#: * ``infra/postgres/130_org_access_control.sql`` seeds the single row. +#: * ``acb_auth.access._BOOTSTRAP_OWNER_SQL`` — first-run ownership recovery at +#: gateway startup, which has no caller to derive a tenant from because its +#: entire reason for existing is that there are no members yet. +#: +#: If a second organization is ever provisioned, neither of those is on a path a +#: request can reach, so neither can answer "which tenant is this request". + # ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── # @@ -99,15 +126,57 @@ async def require_admin_user( # ── Org + role lookups ────────────────────────────────────────────────────── -async def get_org_id(db: Any) -> str: - """Resolve the deployment's organization id, or 503 if unprovisioned.""" - row = ( - await db.execute( - text("SELECT id::text AS id FROM organization WHERE slug = :slug"), - {"slug": DEFAULT_ORG_SLUG}, - ) - ).mappings().first() - if row is None: +#: Asked ONLY on the failure path of :func:`get_org_id`, to tell an operator +#: apart from a stranger. A deployment where migration 130 never ran and a +#: caller who simply has no ``app_user`` row are the same absence to the lookup +#: above, and they need opposite answers: one is "apply the migration", the +#: other is "your account is not set up". Both refuse. +_ANY_ORGANIZATION_SQL = "SELECT 1 FROM organization LIMIT 1" + + +async def get_org_id(db: Any, user: UserContext) -> str: + """The **caller's** organization id, or a refusal. Never a slug. + + ⚠️ **This function used to ignore its caller entirely.** It read + ``WHERE slug = 'default'``, so every route in this package — the roster, + invites, role grants, group membership, permission overrides, the access + queue and ``/auth/me`` — operated on one hard-coded organization regardless + of who asked. With a second tenant that is not a read leak, it is an + unbounded **write into another tenant's access control** by a caller the + permission system correctly authorised for their own + (``multi_tenancy_leak_audit.md`` S1-1). + + **R3: the tenant comes from the authenticated context, never from a request + parameter.** ``user.email`` is asserted by the identity seam + (``acb_auth.deps``, which refuses a bare ``X-User-Email`` when an internal + token is configured), and D-MT-1 (a) makes that email a single-row answer: + ``app_user.email`` is globally UNIQUE, so one person is in exactly one + organization and nothing the caller sends can widen it. There is no + ``org`` argument on any route in this package, and there must not be. + + **Fail closed, and say which failure it is.** A caller with no + ``app_user`` row — an unprovisioned address, or the ``system:internal`` + service principal, which holds ``*`` and belongs to no organization + (``deps.py`` branch 1b) — gets **403** carrying the same + :data:`~gateway.routes.projects.core.NO_ORGANIZATION` message the Projects + write path already uses for exactly this caller. 403 and not 404, for the + reason ``projects.core.require_organization`` states: this says nothing + about what exists, it says the caller's own account is not attached, and a + 404 would send somebody hunting for a record that was never created. R5's + "404, never 403" governs *records* — a member, a role, a group, all of + which now answer 404 across a tenant boundary. + + The **503** is kept for the one case it was actually written for: a + deployment with no ``organization`` row at all. That is an operator fault + with an actionable fix, and collapsing it into the 403 would send an + operator looking at the wrong account. It costs one extra query on a path + that already refuses. + """ + org_id = await resolve_organization_id(db, user.email or "") + if org_id: + return org_id + provisioned = (await db.execute(text(_ANY_ORGANIZATION_SQL))).first() + if provisioned is None: raise HTTPException( status_code=503, detail=( @@ -115,15 +184,26 @@ async def get_org_id(db: Any) -> str: "infra/postgres/130_org_access_control.sql." ), ) - return row["id"] + raise HTTPException(status_code=403, detail=NO_ORGANIZATION) -async def find_member(db: Any, email: str) -> dict[str, Any] | None: - """Fetch one member row by email, or ``None``. +async def find_member(db: Any, org_id: str, email: str) -> dict[str, Any] | None: + """Fetch one member row by email **within one organization**, or ``None``. The non-raising half of :func:`get_member`. Provisioning needs it: it has to know whether the address already has a row *before* it writes one, and a 404 is the wrong answer there — an absent row is the normal case. + + ⚠️ **The ``organization_id`` predicate is half of S1-1's fix, not a + tidy-up.** Making :func:`get_org_id` caller-derived scopes the queries that + take an org id; it does nothing for the ones that reach a person by + address, and every member-targeted route in this package goes through here + — ``PATCH``/``DELETE``/``purge``/``roles``/``overrides`` on + ``/admin/members/{email}``, both group-membership writes, and approve. A + caller-derived org with an unscoped member lookup is the same cross-tenant + write with an extra query in front of it. + + Case-insensitive on the address (R10) and exact on the tenant. """ row = ( await db.execute( @@ -131,17 +211,23 @@ async def find_member(db: Any, email: str) -> dict[str, Any] | None: "SELECT id::text AS id, email, display_name, avatar_url, status, " " role AS legacy_role, invited_by, invited_at, joined_at, " " last_login_at, last_active_at, created_at " - " FROM app_user WHERE lower(email) = :email" + " FROM app_user WHERE lower(email) = :email " + " AND organization_id = CAST(:org AS uuid)" ), - {"email": email.lower().strip()}, + {"email": email.lower().strip(), "org": org_id}, ) ).mappings().first() return dict(row) if row is not None else None -async def get_member(db: Any, email: str) -> dict[str, Any]: - """Fetch one member row by email, or 404.""" - row = await find_member(db, email) +async def get_member(db: Any, org_id: str, email: str) -> dict[str, Any]: + """Fetch one member row by email within one organization, or 404. + + **404, never 403** (R5): a member of another tenant and an address nobody + has ever heard of must be the same answer, or the status code becomes an + oracle for who exists in the deployment. + """ + row = await find_member(db, org_id, email) if row is None: raise HTTPException(status_code=404, detail=f"No member '{email}'.") return row @@ -470,13 +556,37 @@ async def set_roles( #: passes the clause and keeps its pre-extraction behaviour byte-for-byte. #: #: `active` and `suspended` rows are never rewritten by either caller. +#: +#: ⚠️ **The trailing ``WHERE`` is the tenant fence, and it is on the DO UPDATE +#: arm rather than in Python because that is the only place a conflicting row +#: can be seen.** ``app_user.email`` is globally UNIQUE (D-MT-1 (a)), so +#: inviting an address that already belongs to ANOTHER organization conflicts +#: with a row the inviting admin may not touch. Before the fence, this +#: statement's ``SET organization_id = EXCLUDED.organization_id`` *moved that +#: person into the inviter's tenant* — the whole membership, roles about to be +#: replaced by ``set_roles``, in one unauthenticated-by-anything write. That is +#: S1-1's write leak surviving the caller-derived ``get_org_id``, because the +#: id being correct says nothing about the row being reachable. +#: +#: With the fence the arm is skipped, nothing is written, and ``get_member`` +#: below answers 404 — the same answer as an address that does not exist (R5), +#: so the invite form is not an oracle for the deployment's directory. The +#: caller's transaction is abandoned before ``commit`` by that raise. +#: +#: ``organization_id`` is now ``COALESCE(app_user.organization_id, EXCLUDED…)`` +#: rather than ``EXCLUDED`` outright — the same shape, for the same reason, as +#: ``acb_auth.access._BOOTSTRAP_OWNER_SQL``: a row that already has a tenant +#: keeps it, and a legacy row with a NULL one is adopted by the org that is +#: legitimately provisioning it. The ``IS NULL`` arm of the fence is what lets +#: that adoption happen at all. _PROVISION_MEMBER_SQL = """ INSERT INTO app_user (email, display_name, organization_id, status, invited_by, invited_at, joined_at) VALUES (:email, :name, CAST(:org AS uuid), :status, :by, now(), CASE WHEN :status = 'active' THEN now() END) ON CONFLICT (email) DO UPDATE - SET organization_id = EXCLUDED.organization_id, + SET organization_id = COALESCE(app_user.organization_id, + EXCLUDED.organization_id), display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app_user.display_name), status = CASE @@ -494,9 +604,39 @@ async def set_roles( ELSE app_user.joined_at END, updated_at = now() + WHERE app_user.organization_id IS NULL + OR app_user.organization_id = EXCLUDED.organization_id """ +#: ⚠️ **The ONE statement in this package that deliberately crosses the tenant +#: boundary**, and it exists because the database's uniqueness and this +#: package's matching disagree about what "the same address" means. +#: +#: ``app_user_email_key`` is ``UNIQUE (email)`` — **byte-exact**. Every lookup +#: here matches ``lower(email)`` (R10). So a row stored as +#: ``Casey@Alpha.Example`` does **not** conflict with the lower-cased address +#: :func:`provision_member` inserts, and Postgres cheerfully writes a SECOND +#: ``app_user`` row. Found by driving the real routes against a real Postgres: +#: every hermetic test was green, because a fake dict keyed case-insensitively +#: cannot reproduce a byte-exact index. +#: +#: What that costs under D-MT-1 (a): the same human ends up with a row in two +#: organizations, ``resolve_organization_id`` returns whichever the planner +#: hands back first, and a person's tenant becomes non-deterministic. The +#: ``ON CONFLICT`` fence cannot catch it — no conflict ever happens. +#: +#: The proper fix is ``UNIQUE (lower(email))``, which is a migration and is +#: owned elsewhere this wave. Until then the check is here, it answers 404 like +#: every other cross-tenant miss (R5, so this is not an oracle for the +#: deployment's directory), and it returns the address's STORED spelling so the +#: upsert conflicts the way it was always meant to. +_ADDRESS_TENANT_SQL = ( + "SELECT organization_id::text AS org, email FROM app_user " + " WHERE lower(email) = :email" +) + + async def provision_member( db: Any, org_id: str, @@ -531,11 +671,40 @@ async def provision_member( ``set_roles`` below **replaces** a member's assignments wholesale: inviting or approving the last `owner` with the default `member` role would delete the org's only owner grant, and the only way back is SQL on the box. + + ``org_id`` is the CALLER's organization — :func:`get_org_id` derives it from + the authenticated address and every caller of this function passes what it + returned. It bounds three things here and each is a separate door: + :func:`resolve_assignable_roles` (which roles exist to grant), + :func:`find_member` (whose row is being replaced), and the upsert's own + ``WHERE`` fence (whose row may be written at all). """ email = (email or "").strip().lower() if "@" not in email or len(email) > 254: raise HTTPException(status_code=400, detail="A valid email is required.") + # Which tenant already holds this address, if any — asked case-insensitively + # across the whole directory, because the unique index is not. See + # `_ADDRESS_TENANT_SQL` for what this is defending against and why it is + # the only cross-tenant read in the package. + known = ( + await db.execute(text(_ADDRESS_TENANT_SQL), {"email": email}) + ).mappings().all() + if any(r["org"] and r["org"] != org_id for r in known): + raise HTTPException(status_code=404, detail=f"No member '{email}'.") + # Bind the address as it is STORED, so the upsert lands on the existing row + # instead of inserting a differently-cased twin beside it. A brand-new + # address stays lower-cased, which is what makes every future match work. + # + # The row is chosen explicitly — mine, else the unattached one, else the + # lower-cased new address — and never "whichever came back first". A + # directory that already holds a cased pair (which is what this code could + # produce before) must not have its outcome decided by the planner. + stored_email = next( + (r["email"] for r in known if r["org"] == org_id), + next((r["email"] for r in known if not r["org"]), email), + ) + role_ids = await resolve_assignable_roles(db, org_id, roles or ["member"], admin) # Refuse BEFORE the upsert, like every sibling write does (`members.py` @@ -545,7 +714,7 @@ async def provision_member( # route on purpose — it only asks when the grant is actually about to be # taken away, so provisioning is not blocked in an org that has no owner # yet (the bootstrap state, where nothing is being lost). - existing = await find_member(db, email) + existing = await find_member(db, org_id, email) if ( existing is not None and "owner" not in {slug for _rid, slug in role_ids} @@ -557,10 +726,14 @@ async def provision_member( await db.execute( text(_PROVISION_MEMBER_SQL), - {"email": email, "name": display_name or "", "org": org_id, + {"email": stored_email, "name": display_name or "", "org": org_id, "by": admin.email, "status": status}, ) - member = await get_member(db, email) + # Re-read through the SAME tenant predicate the fence uses. When the upsert + # declined a foreign row this is the 404 the caller receives, and it is + # raised before `set_roles` — so no role is granted to a person the caller + # could not have written to in the first place. + member = await get_member(db, org_id, email) await set_roles(db, member["id"], role_ids, admin.email) return member, [slug for _rid, slug in role_ids] diff --git a/apps/services/gateway/gateway/routes/admin/access_requests.py b/apps/services/gateway/gateway/routes/admin/access_requests.py index 1c806b9a..6ed31e46 100644 --- a/apps/services/gateway/gateway/routes/admin/access_requests.py +++ b/apps/services/gateway/gateway/routes/admin/access_requests.py @@ -416,9 +416,9 @@ async def approve_access_request( db = await get_db() async with db: request = await _load_request(db, email, allowed_statuses=("pending",)) - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) - existing = await find_member(db, request["email"]) + existing = await find_member(db, org_id, request["email"]) disposition = _disposition_for(existing) # 409s on suspended/removed detail = "" diff --git a/apps/services/gateway/gateway/routes/admin/groups.py b/apps/services/gateway/gateway/routes/admin/groups.py index 2ac585d1..a189c34b 100644 --- a/apps/services/gateway/gateway/routes/admin/groups.py +++ b/apps/services/gateway/gateway/routes/admin/groups.py @@ -177,7 +177,7 @@ async def list_groups( """ db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) rows = ( await db.execute( text( @@ -204,7 +204,7 @@ async def create_group( slug = _clean_slug(req.slug) db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) clash = ( await db.execute( text( @@ -255,7 +255,7 @@ async def update_group( """ db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) group = await _get_group(db, org_id, slug) await db.execute( text( @@ -293,7 +293,7 @@ async def delete_group( """ db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) group = await _get_group(db, org_id, slug) if slug in CENTER_GROUP_SLUGS: raise HTTPException( @@ -368,9 +368,9 @@ async def add_group_member( db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) group = await _get_group(db, org_id, slug) - member = await get_member(db, req.email) + member = await get_member(db, org_id, req.email) await db.execute( text( @@ -433,9 +433,9 @@ async def remove_group_member( """ db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) group = await _get_group(db, org_id, slug) - member = await get_member(db, email) + member = await get_member(db, org_id, email) result = await db.execute( text( "DELETE FROM org_group_member " diff --git a/apps/services/gateway/gateway/routes/admin/me.py b/apps/services/gateway/gateway/routes/admin/me.py index 2a7f8586..15ec77f2 100644 --- a/apps/services/gateway/gateway/routes/admin/me.py +++ b/apps/services/gateway/gateway/routes/admin/me.py @@ -108,7 +108,13 @@ async def get_me(user: UserContext = Depends(get_current_user)) -> dict[str, Any try: db = await get_db() async with db: - org_id = await get_org_id(db) + # The CALLER's organization, not the deployment's. This line used to + # report the `default` org's slug and display name to every + # signed-in member of every tenant, so the frontend's "which org am + # I in" — `access.organization` in `lib/access.ts`, rendered on the + # Members header — was wrong for all but one + # (`multi_tenancy_leak_audit.md` S1-1). + org_id = await get_org_id(db, user) from sqlalchemy import text # noqa: PLC0415 row = ( diff --git a/apps/services/gateway/gateway/routes/admin/members.py b/apps/services/gateway/gateway/routes/admin/members.py index 09aa46d1..21ae94e6 100644 --- a/apps/services/gateway/gateway/routes/admin/members.py +++ b/apps/services/gateway/gateway/routes/admin/members.py @@ -110,7 +110,7 @@ async def list_members( ) -> list[MemberEntry]: db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) sql = ( "SELECT u.id::text AS id, u.email, u.display_name, u.avatar_url, " " u.status, u.invited_by, u.joined_at, u.last_login_at, " @@ -166,7 +166,7 @@ async def invite_member( db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) member, _assigned = await provision_member( db, org_id, email=email, @@ -206,8 +206,8 @@ async def update_member( db = await get_db() async with db: - org_id = await get_org_id(db) - member = await get_member(db, email) + org_id = await get_org_id(db, admin) + member = await get_member(db, org_id, email) # Invariant 4 — nobody locks themselves out. The same helper guards the # DELETE below: this route reaches the identical `is_active = False`, @@ -242,7 +242,7 @@ async def update_member( {"name": patch.display_name, "uid": member["id"]}, ) await db.commit() - member = await get_member(db, email) + member = await get_member(db, org_id, email) roles = await roles_for_user(db, member["id"]) invalidate_for(member["email"]) @@ -278,8 +278,8 @@ async def remove_member( """ db = await get_db() async with db: - org_id = await get_org_id(db) - member = await get_member(db, email) + org_id = await get_org_id(db, admin) + member = await get_member(db, org_id, email) # Invariant 4, from the same helper the PATCH above calls — this route # used to hold its own copy of the comparison, which is precisely why # the other door never grew one. @@ -585,8 +585,8 @@ async def purge_member( """ db = await get_db() async with db: - org_id = await get_org_id(db) - member = await get_member(db, email) + org_id = await get_org_id(db, admin) + member = await get_member(db, org_id, email) # Invariant 4, from the shared helper — same rule, fourth door. The # outcome name is not an `app_user.status`; the helper's rule is @@ -649,8 +649,8 @@ async def set_member_roles( ) -> MemberEntry: db = await get_db() async with db: - org_id = await get_org_id(db) - member = await get_member(db, email) + org_id = await get_org_id(db, admin) + member = await get_member(db, org_id, email) role_ids = await resolve_assignable_roles(db, org_id, req.roles, admin) # Invariant 4, third door: this route never touches `status`, so @@ -798,8 +798,8 @@ async def get_member_access( """The member's effective access, with provenance for every decision.""" db = await get_db() async with db: - await get_org_id(db) - member = await get_member(db, email) + org_id = await get_org_id(db, admin) + member = await get_member(db, org_id, email) roles = await roles_for_user(db, member["id"]) role_perms = await _role_permission_map(db, member["id"]) overrides = await _load_overrides(db, member["id"]) @@ -884,8 +884,8 @@ async def set_member_overrides( db = await get_db() async with db: - org_id = await get_org_id(db) - member = await get_member(db, email) + org_id = await get_org_id(db, admin) + member = await get_member(db, org_id, email) # An owner who denies themselves admin cannot undo it from the UI. if (member["email"] or "").lower() == (admin.email or "").lower(): @@ -919,7 +919,6 @@ async def set_member_overrides( "reason": reason, "by": admin.email}, ) await db.commit() - _ = org_id invalidate_for(member["email"]) _log.info("member_overrides_set", email=member["email"], by=admin.email, diff --git a/apps/services/gateway/gateway/routes/admin/roles.py b/apps/services/gateway/gateway/routes/admin/roles.py index 3d70e57c..16937c32 100644 --- a/apps/services/gateway/gateway/routes/admin/roles.py +++ b/apps/services/gateway/gateway/routes/admin/roles.py @@ -109,7 +109,7 @@ async def list_roles( ) -> list[RoleEntry]: db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) rows = ( await db.execute( text( @@ -154,7 +154,7 @@ async def create_role( db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) existing = ( await db.execute( text( @@ -231,7 +231,7 @@ async def update_role( ) -> RoleEntry: db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) role = await get_role(db, org_id, slug) if role["is_system"]: raise HTTPException( @@ -297,7 +297,7 @@ async def delete_role( ) -> dict[str, str]: db = await get_db() async with db: - org_id = await get_org_id(db) + org_id = await get_org_id(db, admin) role = await get_role(db, org_id, slug) if role["is_system"]: raise HTTPException( diff --git a/tests/unit/_admin_fakes.py b/tests/unit/_admin_fakes.py index f1051f93..a4cb331d 100644 --- a/tests/unit/_admin_fakes.py +++ b/tests/unit/_admin_fakes.py @@ -15,10 +15,18 @@ from __future__ import annotations import re +from types import SimpleNamespace from typing import Any, ClassVar ORG = "00000000-0000-0000-0000-00000000000a" +#: A SECOND tenant. Every fixture in the existing files seeds only :data:`ORG`, +#: which is exactly why they could not have caught S1-1: a one-organization +#: world cannot tell a route that resolves the caller's tenant from one that +#: resolves a hard-coded slug — both answer `ORG`. ``test_admin_tenancy.py`` +#: seeds both. +ORG_B = "00000000-0000-0000-0000-00000000000b" + # ── Person-scoped counts and deletes (members.purge_member) ───────────────── # # The purge addresses twenty-odd tables with two statements each, built from @@ -80,6 +88,19 @@ def all(self) -> list[dict[str, Any]]: def fetchall(self) -> list[Any]: return [tuple(r.values()) for r in self._rows] + def fetchone(self) -> Any: + """Attribute access, the way ``resolve_organization_id`` reads it. + + ``projects.core.resolve_organization_id`` — the ONE tenant lookup this + package now shares — does ``getattr(row, "organization_id", None)``, + not ``row["organization_id"]``. A shim that only spoke mappings would + make it return ``None`` for every caller, i.e. make the whole admin + surface 403 in tests while passing in production. + """ + if not self._rows: + return None + return SimpleNamespace(**self._rows[0]) + def scalars(self) -> _Scalars: return _Scalars(self._rows) @@ -172,6 +193,25 @@ def __init__(self) -> None: #: modelled above and the purge branch reads and writes those, so a #: test sees one world rather than two. self.rows: dict[str, list[dict[str, Any]]] = {} + #: ``organization`` — id → row. Seeded with the single tenant the + #: pre-retrofit files assume; ``test_admin_tenancy.py`` adds a second. + self.organizations: dict[str, dict[str, Any]] = { + ORG: {"id": ORG, "slug": "default", "display_name": "Default Org"}, + } + #: ``org_group`` — id → row, each carrying its own ``organization_id``. + #: Group slugs are UNIQUE **per organization**, so `engineering` is a + #: legal slug in every tenant at once and matching on the bare slug + #: spans them (leak audit S2-5). + self.groups: dict[str, dict[str, Any]] = {} + #: ``org_group_member`` — (group_id, user_id) → row. + self.group_members: dict[tuple[str, str], dict[str, Any]] = {} + #: ``user_permission_override`` — (user_id, permission) → row. + self.overrides: dict[tuple[str, str], dict[str, Any]] = {} + #: Does the deployment have an ``organization`` row at all? Only + #: ``get_org_id``'s failure path asks, and only to tell an operator + #: whose migration never ran (503) apart from a caller whose account is + #: not attached (403). Set ``False`` to model the unprovisioned box. + self.provisioned = True self.committed = 0 self.invalidated: list[str] = [] #: Audit calls, in order, as ``(action, target)``. Ordered because the @@ -264,12 +304,33 @@ def _person_delete(self, table: str, matched: list[dict[str, Any]]) -> None: # helpers ----------------------------------------------------------- def seed_user(self, uid: str, email: str, *, status: str = "active", - name: str = "", joined_at: str | None = None) -> None: + name: str = "", joined_at: str | None = None, + organization_id: str | None = ORG) -> None: + """Seed a directory row. ``organization_id`` defaults to :data:`ORG`. + + Defaulted rather than required so the single-tenant files that predate + the retrofit read unchanged; ``None`` models the legacy row migration + 130 left unattached, which is the only row a provisioning upsert may + adopt into a tenant. + """ self.users[uid] = { "id": uid, "email": email, "display_name": name, "avatar_url": "", "status": status, "legacy_role": "employee", "invited_by": "", "invited_at": None, "joined_at": joined_at, "last_login_at": None, "last_active_at": None, "created_at": None, + "organization_id": organization_id, + } + + def seed_organization(self, org_id: str, slug: str, name: str) -> None: + self.organizations[org_id] = { + "id": org_id, "slug": slug, "display_name": name, + } + + def seed_group(self, gid: str, slug: str, *, organization_id: str = ORG, + name: str | None = None) -> None: + self.groups[gid] = { + "id": gid, "slug": slug, "display_name": name or slug.title(), + "description": "", "organization_id": organization_id, } def seed_request(self, email: str, *, status: str = "pending", @@ -305,11 +366,86 @@ async def execute( # noqa: C901 — one branch per statement, by design p = params or {} self.statements.append(s) - if "FROM organization WHERE slug" in s: - return _Rows([{"id": ORG}]) + # ── The caller's tenant (WS-29e / S1-1) ───────────────────────────── + # + # ⚠️ There is deliberately NO `FROM organization WHERE slug` branch any + # more. It used to answer `ORG` unconditionally, which is what made the + # hard-coded-slug bug invisible to every test in this suite: the fake + # agreed that the deployment's org and the caller's org were the same + # thing, because in a one-organization world they are. + if "FROM app_user au" in s and "organization_id" in s: + # `projects.core._MY_ORGANIZATION_SQL`, read for real: the ACTIVE + # row for this address, and its tenant. An address with no row, or + # an inactive one, resolves to nothing — which is what makes + # `get_org_id` fail closed rather than fall back. + row = self.user_by_email(p["email"]) + if row is None or row.get("status") != "active": + return _Rows([]) + org = row.get("organization_id") + return _Rows([{"organization_id": org}] if org else []) + + if "FROM organization LIMIT 1" in s: + return _Rows([{"one": 1}] if self.provisioned else []) + + if "FROM organization WHERE id" in s: + # `/auth/me` naming the caller's org back to the browser. Answered + # BY ID, which is the whole change: it used to be answered by the + # literal slug `default` for every signed-in member of every tenant. + row = self.organizations.get(p["id"]) + return _Rows([dict(row)] if row else []) + + if "FROM feature_catalog" in s: + return _Rows([{"slug": "projects"}]) + + if "FROM app_user u" in s and "u.organization_id = CAST(:org AS uuid)" in s: + # `members.list_members` — the roster. The tenant predicate is read + # from the statement, so a route that stops scoping the roster + # shows this fake's other organization and fails. + rows = [ + u for u in self.users.values() + if u.get("organization_id") == p.get("org") + ] + if "u.status <> 'removed'" in s: + rows = [u for u in rows if u["status"] != "removed"] + return _Rows([ + dict(u) | {"roles": list(self.user_roles.get(u["id"], []))} + for u in sorted(rows, key=lambda u: u["email"]) + ]) + + if "FROM org_group" in s and "AND slug = :slug" in s: + # `groups._get_group` — by slug WITHIN one organization. + row = next( + (g for g in self.groups.values() + if g["slug"] == p["slug"] + and g["organization_id"] == p.get("org")), None, + ) + return _Rows([dict(row)] if row else []) + + if "INSERT INTO org_group_member" in s: + key = (p["gid"], p["uid"]) + existing = self.group_members.get(key) + if existing is None: + self.group_members[key] = {"role": p["role"], "added_by": p["by"]} + else: + existing["role"] = p["role"] + return _Rows([], rowcount=1) + + if "INSERT INTO user_permission_override" in s: + key = (p["uid"], p["perm"]) + if key in self.overrides: # ON CONFLICT DO NOTHING + return _Rows([], rowcount=0) + self.overrides[key] = { + "effect": p.get("effect", "allow"), "reason": p.get("reason", ""), + "set_by": p.get("by", ""), + } + return _Rows([], rowcount=1) if "MIN(r.rank)" in s: me = self.user_by_email(p["email"]) + # `caller_rank`'s SQL joins `org_role` on the org, so a caller's + # rank in a tenant they do not belong to is no rank at all. + if me is not None and me.get("organization_id") != p.get("org"): + return _Rows([{"rank": None}]) slugs = self.user_roles.get(me["id"], []) if me else [] ranks = [self.ROLE_RANKS[x] for x in slugs if x in self.ROLE_RANKS] return _Rows([{"rank": min(ranks) if ranks else None}]) @@ -340,16 +476,63 @@ async def execute( # noqa: C901 — one branch per statement, by design for slug in p["slugs"] if slug in self.ROLE_RANKS ]) + if "SELECT organization_id::text AS org, email FROM app_user" in s: + # `_ADDRESS_TENANT_SQL` — the one deliberately cross-tenant read. + want = str(p["email"]).lower() + return _Rows([ + {"org": u.get("organization_id"), "email": u["email"]} + for u in self.users.values() if u["email"].lower() == want + ]) + if "INSERT INTO app_user" in s: - existing = self.user_by_email(p["email"]) + # ⚠️ **BYTE-EXACT**, mirroring `app_user_email_key`, which is + # `UNIQUE (email)` and NOT `UNIQUE (lower(email))`. A fake that + # matched case-insensitively here would agree that a lower-cased + # invite of `Casey@Alpha.Example` conflicts — it does not, and the + # duplicate row Postgres writes instead is a live finding this + # class was previously unable to express. + existing = next( + (u for u in self.users.values() if u["email"] == p["email"]), + None, + ) if existing is None: uid = f"u-{len(self.users) + 1}" self.seed_user(uid, p["email"], status=p.get("status", "invited"), - name=p.get("name", "")) + name=p.get("name", ""), + organization_id=p.get("org")) self.users[uid]["invited_by"] = p.get("by", "") if p.get("status") == "active": self.users[uid]["joined_at"] = "now()" return _Rows([], rowcount=1) + + # ⚠️ The DO UPDATE arm's own `WHERE`, read from the STATEMENT and + # not restated as a rule: a conflicting row belonging to another + # tenant is not written at all. `app_user.email` is globally UNIQUE + # (D-MT-1 (a)), so this arm is the only place a cross-tenant row can + # be reached by an INSERT, and deleting the fence from the SQL + # changes what this branch does rather than being shrugged at. + # Both arms are read separately, not as one "is it fenced" flag: + # dropping the `IS NULL` arm is a different defect from dropping + # the whole fence — it locks out the pre-130 rows that have no + # tenant yet, which looks identical to a correct refusal. + allows_null = "app_user.organization_id IS NULL" in s + allows_match = "app_user.organization_id = EXCLUDED.organization_id" in s + org = existing.get("organization_id") + if allows_null or allows_match: + writable = ( + (allows_null and org is None) + or (allows_match and org is not None and org == p.get("org")) + ) + if not writable: + return _Rows([], rowcount=0) + + # `SET organization_id = COALESCE(app_user.organization_id, …)` — + # also read from the statement, so reverting it to the bare + # `EXCLUDED.organization_id` (the tenant STEAL) is visible here. + keeps_tenant = "COALESCE(app_user.organization_id" in s + if org is None or not keeps_tenant: + existing["organization_id"] = p.get("org") + # ON CONFLICT (email) DO UPDATE — mirror of _PROVISION_MEMBER_SQL's # CASE arms. Keep in step with it; the structural test is the fence. if p.get("name"): @@ -365,6 +548,17 @@ async def execute( # noqa: C901 — one branch per statement, by design if "FROM app_user WHERE lower(email)" in s: row = self.user_by_email(p["email"]) + # `find_member`'s tenant predicate, read from the statement for the + # same reason as the fence above: a member lookup that drops it + # hands every member-targeted route in the package a row from + # another organization, and a caller-derived `get_org_id` in front + # of it changes nothing about that. + if ( + row is not None + and "organization_id = CAST(:org AS uuid)" in s + and row.get("organization_id") != p.get("org") + ): + row = None return _Rows([dict(row)] if row else []) if "UPDATE app_user SET status = :status" in s: @@ -396,12 +590,17 @@ async def execute( # noqa: C901 — one branch per statement, by design return _Rows([], rowcount=1) if "r.slug = 'owner'" in s: - # owner_count(): how many ACTIVE members would still hold `owner`. + # owner_count(): how many ACTIVE members would still hold `owner` + # IN THIS ORGANIZATION — the real statement joins `org_role` on it, + # and invariant 1 is per-tenant: another company having an owner + # does not stop this one going ownerless. excluded = p.get("uid") return _Rows([{"count": sum( 1 for uid, slugs in self.user_roles.items() if "owner" in slugs and uid != excluded and (self.users.get(uid) or {}).get("status") == "active" + and (self.users.get(uid) or {}).get("organization_id") + == p.get("org") )}]) if "SELECT r.slug FROM user_role ur" in s: diff --git a/tests/unit/test_admin_groups.py b/tests/unit/test_admin_groups.py index 0b63142d..62383120 100644 --- a/tests/unit/test_admin_groups.py +++ b/tests/unit/test_admin_groups.py @@ -21,6 +21,7 @@ """ from __future__ import annotations +from types import SimpleNamespace from typing import Any import pytest @@ -74,6 +75,12 @@ def mappings(self) -> _Rows: def first(self) -> dict[str, Any] | None: return self._rows[0] if self._rows else None + def fetchone(self) -> Any: + """`resolve_organization_id` reads the tenant by ATTRIBUTE, not by key.""" + if not self._rows: + return None + return SimpleNamespace(**self._rows[0]) + def all(self) -> list[dict[str, Any]]: return self._rows @@ -100,12 +107,14 @@ def __init__(self) -> None: self.committed = 0 # helpers ----------------------------------------------------------- - def seed_user(self, uid: str, email: str, name: str = "") -> None: + def seed_user(self, uid: str, email: str, name: str = "", + organization_id: str = ORG) -> None: self.users[uid] = { "id": uid, "email": email, "display_name": name, "avatar_url": "", "status": "active", "legacy_role": "employee", "invited_by": "", "invited_at": None, "joined_at": None, "last_login_at": None, "last_active_at": None, "created_at": None, + "organization_id": organization_id, } def seed_group(self, gid: str, slug: str, name: str | None = None, @@ -136,14 +145,35 @@ async def execute( # noqa: C901 — one branch per SQL statement, by design s = " ".join(str(sql).split()) p = params or {} - if "FROM organization WHERE slug" in s: - return _Rows([{"id": ORG}]) + # The caller's tenant (WS-29e / S1-1). There is no `FROM organization + # WHERE slug` branch any more: `get_org_id` no longer asks that + # question, and a fake that kept answering it would keep agreeing that + # the deployment's organization and the caller's are the same row. + if "FROM app_user au" in s and "organization_id" in s: + row = next( + (u for u in self.users.values() + if u["email"].lower() == p["email"] + and u["status"] == "active"), None, + ) + return _Rows([{"organization_id": row["organization_id"]}] + if row and row["organization_id"] else []) + + if "FROM organization LIMIT 1" in s: + return _Rows([{"one": 1}]) if "FROM app_user WHERE lower(email)" in s: row = next( (u for u in self.users.values() if u["email"].lower() == p["email"]), None, ) + # `find_member`'s tenant predicate, read from the statement so + # dropping it changes the answer rather than being shrugged at. + if ( + row is not None + and "organization_id = CAST(:org AS uuid)" in s + and row["organization_id"] != p.get("org") + ): + row = None return _Rows([row] if row else []) if "SELECT 1 FROM org_group " in s: @@ -238,6 +268,12 @@ async def _get_db() -> _FakeDB: ), ) monkeypatch.setattr(groups, "record_admin_change", lambda *a, **k: None) + # The acting admins need directory rows of their own: since WS-29e the + # routes derive the tenant from the CALLER (R3), so an admin the directory + # does not know has no organization and is refused 403 before any group is + # touched. `test_admin_tenancy.py` is where that refusal is asserted. + fake.seed_user("u-full-admin", FULL_ADMIN.email or "", "Admin") + fake.seed_user("u-roster-admin", ROSTER_ADMIN.email or "", "Roster") return fake diff --git a/tests/unit/test_admin_member_offboarding.py b/tests/unit/test_admin_member_offboarding.py index 46d579e7..5544a78a 100644 --- a/tests/unit/test_admin_member_offboarding.py +++ b/tests/unit/test_admin_member_offboarding.py @@ -337,11 +337,21 @@ async def test_purging_yourself_is_refused_by_the_same_guard(db: _FakeDB) -> Non async def test_re_activating_your_own_row_is_not_a_lockout(db: _FakeDB) -> None: """dw2 — `active` is the one status that gives access rather than taking - it, so the guard has no business refusing it.""" + it, so the guard has no business refusing it. + + ⚠️ **This test used to suspend the caller's own row first, and that world is + now unreachable.** Since WS-29e the route derives the caller's tenant from + their own ACTIVE directory row (``projects.core.resolve_organization_id``), + so a suspended caller is refused by :func:`_common.get_org_id` before the + self-guard is consulted — which merely makes explicit what + ``EffectiveAccess.is_active`` already decided: a suspended member holds no + permissions and never passes ``require_admin_user`` in the first place, so + they could never have issued this PATCH in production either. What is left + is the claim the test was actually written to make, and it is unchanged: + ``status="active"`` on your own row is not a lockout and is not refused. + """ from gateway.routes.admin.members import MemberPatch, update_member - db.users["u-owner"]["status"] = "suspended" - entry = await update_member( "owner@fracktal.in", MemberPatch(status="active"), admin=OWNER, ) @@ -479,17 +489,35 @@ async def test_a_caller_with_no_address_of_their_own_matches_nobody( A caller whose identity header never arrived has ``email == ""``; comparing two blanks would refuse (or, on a row with no address, refuse everything) - for a reason that has nothing to do with self-off-boarding. The permission - gate is what stops an anonymous caller here, not this guard. + for a reason that has nothing to do with self-off-boarding. That claim is + about :func:`_common.assert_not_self_lockout` alone and is asserted against + the function directly, because since WS-29e the ROUTE no longer reaches it: + an identity-less caller has no tenant to derive, so ``get_org_id`` refuses + first. Both halves are checked here — the guard's rule, and the fact that + the route now refuses earlier and writes nothing. """ + from gateway.routes.admin._common import assert_not_self_lockout from gateway.routes.admin.members import MemberPatch, update_member - entry = await update_member( - "priya@fracktal.in", MemberPatch(status="suspended"), - admin=_caller(""), + # The guard itself: two blanks are not a match. + assert_not_self_lockout( + _caller(""), {"email": ""}, status="suspended", + ) + assert_not_self_lockout( + _caller(""), {"email": "priya@fracktal.in"}, status="suspended", ) - assert entry.status == "suspended" + # And the route, which no longer gets that far. R3: identity comes from the + # authenticated context, and an absent one resolves to no organization + # rather than to the deployment's. + with pytest.raises(HTTPException) as exc: + await update_member( + "priya@fracktal.in", MemberPatch(status="suspended"), + admin=_caller(""), + ) + assert exc.value.status_code == 403 + assert db.users["u-priya"]["status"] == "active" + _nothing_was_written(db) # ════════════════════════════════════════════════════════════════════════════ diff --git a/tests/unit/test_admin_tenancy.py b/tests/unit/test_admin_tenancy.py new file mode 100644 index 00000000..d5bde210 --- /dev/null +++ b/tests/unit/test_admin_tenancy.py @@ -0,0 +1,645 @@ +"""The admin plane · the TENANT boundary — what one organization's admin +cannot reach in another (WS-29e). + +Spec: ``ai-company-brain/specs/multi_tenancy_leak_audit.md`` S1-1 and +``multi_tenancy.md`` §3 (D-MT-1 (a)). + +``test_projects_tenancy.py`` fences the READ path: one company's portfolio +against another's. This file fences the **write** path into access control +itself, and the audit ranks it above every read leak in the system for one +reason — *it grants further access*. A tenant-B admin who can invite into +tenant A, or grant a role there, does not merely see A's data; they mint a +principal that can. + +**The defect this file exists to prevent, stated exactly.** +``_common.get_org_id`` resolved the tenant with +``SELECT id FROM organization WHERE slug = 'default'`` and **never consulted +the caller**. Twenty-six call sites inherited it — the roster, invites, member +status, purge, role assignment, permission overrides, group membership, the +sign-in queue and ``GET /auth/me``. Every one of them was operating on the +`default` organization no matter who asked. + +⚠️ **Every test here seeds TWO organizations, and the acting admin of each is a +real directory row.** A one-organization suite cannot tell a caller-derived +tenant from a hard-coded one: both answer `ORG`. That is precisely why the +existing admin suites — which are thorough about invariants 1 through 4 — were +all green while this was live. + +Three shapes of defect are covered, because closing only the first leaves the +bug intact: + +1. **The tenant id.** ``get_org_id`` must read the caller (R3), and must fail + closed when they have none. +2. **The row reached by address.** Every member-targeted route finds its + subject through ``find_member``/``get_member``, which took no organization + at all. A caller-derived id in front of an unscoped lookup is the same + cross-tenant write with an extra query before it. +3. **The upsert.** ``app_user.email`` is globally UNIQUE (D-MT-1 (a)), so + inviting an address that belongs to another tenant CONFLICTS with their row + — and the ``DO UPDATE`` arm used to move that person into the inviter's + organization. + +Hermetic: no Postgres, no network, no TestClient — the route functions are +called directly with the DB seam monkeypatched onto each SUT submodule, the +house convention of ``test_admin_groups.py`` and ``test_signin_requests.py``. +The database's own half (the unique index that makes the conflict happen at +all) is proved against a real Postgres, not here. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from acb_auth import UserContext, UserRole, build_access +from fastapi import HTTPException +from gateway.routes.admin import _common, access_requests, groups, me, members + +from tests.unit._admin_fakes import ORG, ORG_B, _FakeDB, bind_admin_db + +#: The write modules — these have the cache and audit seams to bind. +MODULES = (members, groups, access_requests) + +ADMIN_PERMISSIONS = [ + "admin:members:read", + "admin:members:invite", + "admin:members:manage", + "admin:access:manage", +] + + +def _admin(email: str) -> UserContext: + """A caller who has already passed every permission gate. + + The routes are invoked directly, so FastAPI's dependencies do not run. + That is the point: **both admins below are correctly authorised.** Nothing + in this file is about a missing permission — it is about a caller the + permission system was right to admit reaching the wrong organization. + """ + return UserContext( + email=email, role=UserRole.EXECUTIVE, + access=build_access(ADMIN_PERMISSIONS, roles=["admin"]), + ) + + +#: One admin per tenant. D-MT-1 (a): one email, one person, one organization — +#: which is what makes the tenant derivable from `X-User-Email` alone. +ANA = _admin("ana@alpha.example") # organization A (`ORG`) +BEN = _admin("ben@beta.example") # organization B (`ORG_B`) + +#: ⚠️ A caller the directory has never heard of, holding a full admin set. Not +#: a contradiction: `EXECUTIVE_EMAILS` bootstrap, a service principal +#: (`system:internal` holds `*` and has no `app_user` row at all — `deps.py` +#: branch 1b), or an address provisioned in the IdP and not here. +STRANGER = _admin("nobody@nowhere.example") + + +@pytest.fixture() +def db(monkeypatch: pytest.MonkeyPatch) -> _FakeDB: + """Two organizations, one admin and one colleague each.""" + fake = _FakeDB() + bind_admin_db(monkeypatch, fake, MODULES) + # `/auth/me` is a read: it has the DB seam and neither of the write seams. + + async def _get_db() -> _FakeDB: + return fake + + monkeypatch.setattr(me, "get_db", _get_db) + + fake.seed_organization(ORG, "alpha", "Alpha Industries") + fake.seed_organization(ORG_B, "beta", "Beta Consulting") + + fake.seed_user("u-ana", "ana@alpha.example", organization_id=ORG) + fake.user_roles["u-ana"] = ["owner"] + fake.seed_user("u-alpha-1", "priya@alpha.example", organization_id=ORG) + fake.user_roles["u-alpha-1"] = ["member"] + + fake.seed_user("u-ben", "ben@beta.example", organization_id=ORG_B) + fake.user_roles["u-ben"] = ["owner"] + fake.seed_user("u-beta-1", "bob@beta.example", organization_id=ORG_B) + fake.user_roles["u-beta-1"] = ["member"] + return fake + + +def _nothing_was_written(db: _FakeDB) -> None: + """No commit, no cache invalidation, no audit entry. + + A refusal that lands after something was written, or that records the act + it declined, is only half a refusal. + """ + assert db.committed == 0 + assert db.invalidated == [] + assert db.audit == [] + + +# ════════════════════════════════════════════════════════════════════════════ +# 1. The resolver itself — R3 +# ════════════════════════════════════════════════════════════════════════════ + +async def test_the_tenant_comes_from_the_caller_not_from_a_slug( + db: _FakeDB, +) -> None: + """⚠️ THE line. ``get_org_id`` used to answer `default` for everybody. + + What breaks without it: every assertion further down this file, because + every route inherits this one answer. Both callers below are full admins + and the ONLY thing that differs is which directory row their address is on. + """ + assert await _common.get_org_id(db, ANA) == ORG + assert await _common.get_org_id(db, BEN) == ORG_B + + +async def test_the_answer_is_case_insensitive_on_both_sides(db: _FakeDB) -> None: + """R10. An IdP may return a UPN cased differently between sessions, and a + tenant that switches off when someone's address arrives in title case is + not a boundary — it is a coincidence of casing.""" + db.seed_user("u-upper", "Casey@Alpha.Example", organization_id=ORG) + assert await _common.get_org_id(db, _admin("CASEY@ALPHA.EXAMPLE")) == ORG + assert await _common.get_org_id(db, _admin(" casey@alpha.example ")) == ORG + + +async def test_a_caller_with_no_organization_is_refused_not_defaulted( + db: _FakeDB, +) -> None: + """⚠️ The fallback IS the bug. + + A caller the directory does not know is exactly the case a + ``DEFAULT_ORG_SLUG`` fallback would answer — and answering it hands a + stranger, or the `*`-holding internal service principal, the `default` + organization's entire access control. Absence must refuse. + + 403 rather than 404 for the reason ``projects.core.require_organization`` + states: this says nothing about what exists, it says the caller's own + account is not attached. R5's "404, never 403" governs RECORDS, and the + records — members, groups, roles — do answer 404 below. + """ + with pytest.raises(HTTPException) as exc: + await _common.get_org_id(db, STRANGER) + assert exc.value.status_code == 403 + assert "not attached to an organization" in exc.value.detail + + +async def test_an_identity_less_caller_has_no_organization(db: _FakeDB) -> None: + """The empty string is not everybody, and it is not `default` either.""" + with pytest.raises(HTTPException) as exc: + await _common.get_org_id(db, _admin("")) + assert exc.value.status_code == 403 + + +async def test_a_suspended_caller_resolves_to_nothing(db: _FakeDB) -> None: + """The lookup asks for an ACTIVE row, inherited from the Projects seam. + + Consistent with the rest of the model rather than a new rule: + ``EffectiveAccess.is_active`` is ``status == 'active'`` exactly, so a + suspended member holds no permissions and never passes + ``require_admin_user``. Their tenant resolving to nothing means the two + layers agree instead of one of them having to remember. + """ + db.users["u-ana"]["status"] = "suspended" + with pytest.raises(HTTPException) as exc: + await _common.get_org_id(db, ANA) + assert exc.value.status_code == 403 + + +async def test_an_unprovisioned_deployment_still_says_so(db: _FakeDB) -> None: + """The 503 this function was originally written for is kept, and it is a + DIFFERENT failure from the 403 above: one is an operator whose migration + never ran, the other is a caller whose account is not set up. Collapsing + them sends an operator looking at the wrong thing. Both refuse.""" + db.provisioned = False + with pytest.raises(HTTPException) as exc: + await _common.get_org_id(db, STRANGER) + assert exc.value.status_code == 503 + assert "130_org_access_control.sql" in exc.value.detail + + +# ════════════════════════════════════════════════════════════════════════════ +# 2. SEE — the roster and the member record +# ════════════════════════════════════════════════════════════════════════════ + +async def test_each_admin_sees_only_their_own_roster(db: _FakeDB) -> None: + """The read every admin opens first. Same route, same permissions, two + answers — and only the caller's directory row can have chosen between + them.""" + assert sorted(m.email for m in await members.list_members(admin=ANA)) == [ + "ana@alpha.example", "priya@alpha.example", + ] + assert sorted(m.email for m in await members.list_members(admin=BEN)) == [ + "ben@beta.example", "bob@beta.example", + ] + + +async def test_reading_another_tenants_member_is_a_404_not_a_403( + db: _FakeDB, +) -> None: + """R5. "No such member" and "not in your organization" must be the same + answer, or the status code is an oracle for who exists in the deployment — + and a member roster is a customer list.""" + with pytest.raises(HTTPException) as exc: + await members.get_member_access("priya@alpha.example", BEN) + assert exc.value.status_code == 404 + + +async def test_auth_me_names_the_callers_own_organization(db: _FakeDB) -> None: + """``GET /auth/me`` reported the `default` org's slug and display name to + every signed-in member of every tenant, so the frontend's idea of "which + organization am I in" (`lib/access.ts` → the Members page header) was + wrong for all but one.""" + assert (await me.get_me(user=ANA))["organization"]["slug"] == "alpha" + assert (await me.get_me(user=BEN))["organization"]["slug"] == "beta" + + +async def test_auth_me_reports_no_organization_rather_than_a_default( + db: _FakeDB, +) -> None: + """A caller with no directory row gets an empty object — the frontend + already renders that as the neutral "Organization" — never somebody + else's name.""" + assert (await me.get_me(user=STRANGER))["organization"] == {} + + +# ════════════════════════════════════════════════════════════════════════════ +# 3. INVITE INTO — the upsert, and the tenant steal +# ════════════════════════════════════════════════════════════════════════════ + +async def test_inviting_into_another_tenant_provisions_nothing( + db: _FakeDB, +) -> None: + """Ben invites a NEW address; it lands in Beta, never in Alpha.""" + await members.invite_member( + members.InviteRequest(email="new@beta.example"), admin=BEN, + ) + assert db.user_by_email("new@beta.example")["organization_id"] == ORG_B + + +async def test_inviting_another_tenants_member_cannot_steal_their_row( + db: _FakeDB, +) -> None: + """⚠️ The write the caller-derived org id does NOT close on its own. + + `app_user.email` is globally UNIQUE, so Ben inviting Priya conflicts with + Alpha's row. Before the fence, ``ON CONFLICT (email) DO UPDATE SET + organization_id = EXCLUDED.organization_id`` **moved Priya into Beta** — + and ``set_roles``, which replaces assignments wholesale, was next in the + same transaction. One POST, and another company's member is yours. + + The refusal is a 404 (R5): the invite form must not answer "that address + exists somewhere in this deployment". + """ + with pytest.raises(HTTPException) as exc: + await members.invite_member( + members.InviteRequest(email="priya@alpha.example", roles=["member"]), + admin=BEN, + ) + assert exc.value.status_code == 404 + + assert db.users["u-alpha-1"]["organization_id"] == ORG + assert db.users["u-alpha-1"]["status"] == "active" + assert db.user_roles["u-alpha-1"] == ["member"] + _nothing_was_written(db) + + +async def test_a_legacy_row_with_no_tenant_is_still_adoptable( + db: _FakeDB, +) -> None: + """The other direction, and the reason the fence has an ``IS NULL`` arm. + + Migration 130 added ``app_user.organization_id`` to rows that predate it. + A fence written as "the tenants must match" would refuse to provision any + of them — which looks identical to a correct refusal and is a lockout, not + a boundary. Same shape as ``acb_auth.access._BOOTSTRAP_OWNER_SQL``. + """ + db.seed_user("u-orphan", "old@nowhere.example", status="invited", + organization_id=None) + + await members.invite_member( + members.InviteRequest(email="old@nowhere.example"), admin=BEN, + ) + assert db.users["u-orphan"]["organization_id"] == ORG_B + + +async def test_a_differently_cased_address_is_the_same_person( + db: _FakeDB, +) -> None: + """⚠️ **Found on live Postgres, invisible to every hermetic test before it.** + + ``app_user_email_key`` is ``UNIQUE (email)`` — **byte-exact**. Every lookup + in this package matches ``lower(email)`` (R10). So Alpha's row spelled + ``Casey@Alpha.Example`` does not conflict with the lower-cased address + ``provision_member`` inserts, the ``ON CONFLICT`` fence never fires because + there is no conflict, and Postgres writes a SECOND ``app_user`` row — + the same human, in two organizations. + + That is D-MT-1 (a) broken at the root: ``resolve_organization_id`` returns + whichever row the planner hands back, so the person's tenant becomes + non-deterministic and their whole visibility with it. + + The fake now models the index byte-exactly, which is what lets this run + here at all. It was measured green against the case-insensitive fake and + red against Postgres — the gap, not the test, is the finding. + """ + db.seed_user("u-casey", "Casey@Alpha.Example", organization_id=ORG) + + with pytest.raises(HTTPException) as exc: + await members.invite_member( + members.InviteRequest(email="casey@alpha.example"), admin=BEN, + ) + assert exc.value.status_code == 404 + + caseys = [u for u in db.users.values() + if u["email"].lower() == "casey@alpha.example"] + assert len(caseys) == 1, "a differently-cased twin was written" + assert caseys[0]["organization_id"] == ORG + + +async def test_re_inviting_your_own_member_does_not_write_a_cased_twin( + db: _FakeDB, +) -> None: + """The same defect inside ONE tenant, which is where it is reachable + without any adversary at all: re-inviting a colleague whose row the IdP + stored in title case. Nothing cross-tenant happens, and two rows for one + person is still the thing that makes their organization ambiguous.""" + db.seed_user("u-casey", "Casey@Alpha.Example", status="invited", + organization_id=ORG) + + await members.invite_member( + members.InviteRequest(email="CASEY@alpha.example"), admin=ANA, + ) + + caseys = [u for u in db.users.values() + if u["email"].lower() == "casey@alpha.example"] + assert len(caseys) == 1 + assert caseys[0]["id"] == "u-casey" + + +async def test_approving_a_sign_in_request_cannot_reach_across_either( + db: _FakeDB, +) -> None: + """The second provisioning door. + + ``access_request`` has no tenant column and genuinely cannot have one — an + address knocking has no organization yet (leak audit §5). So the queue is + shared, and the fence has to be on what approval WRITES rather than on + what it reads. Ben approving Alpha's member must not adopt them. + """ + db.seed_request("priya@alpha.example") + + with pytest.raises(HTTPException) as exc: + await access_requests.approve_access_request( + "priya@alpha.example", + access_requests.ApproveRequest(roles=["member"]), + admin=BEN, + ) + assert exc.value.status_code == 404 + + assert db.users["u-alpha-1"]["organization_id"] == ORG + assert db.requests["priya@alpha.example"]["status"] == "pending" + _nothing_was_written(db) + + +# ════════════════════════════════════════════════════════════════════════════ +# 4. GRANT A ROLE IN — the write the audit ranks worst +# ════════════════════════════════════════════════════════════════════════════ + +async def test_granting_a_role_in_another_tenant_is_a_404(db: _FakeDB) -> None: + """⚠️ The worst shape of leak in the system: a cross-tenant write **into + access control**, by a correctly-authorised caller. It does not end at the + response — it mints a principal in another company.""" + with pytest.raises(HTTPException) as exc: + await members.set_member_roles( + "priya@alpha.example", + members.RoleAssignment(roles=["owner"]), admin=BEN, + ) + assert exc.value.status_code == 404 + assert db.user_roles["u-alpha-1"] == ["member"] + _nothing_was_written(db) + + +async def test_writing_an_override_in_another_tenant_is_a_404( + db: _FakeDB, +) -> None: + """The other half of access control — per-user allow/deny. A deny written + into another tenant is a denial of service on a colleague nobody in that + company chose to restrict.""" + with pytest.raises(HTTPException) as exc: + await members.set_member_overrides( + "priya@alpha.example", + members.OverrideRequest(overrides=[ + members.OverrideEntry(permission="feature:email", effect="deny"), + ]), + admin=BEN, + ) + assert exc.value.status_code == 404 + _nothing_was_written(db) + + +async def test_suspending_and_removing_reach_only_your_own_tenant( + db: _FakeDB, +) -> None: + """Both doors to ``is_active = False``, across the boundary. Locking a + rival's staff out of their own tooling is one PATCH.""" + for call in ( + members.update_member( + "priya@alpha.example", + members.MemberPatch(status="suspended"), admin=BEN, + ), + members.remove_member("priya@alpha.example", admin=BEN), + members.purge_member("priya@alpha.example", admin=BEN), + ): + with pytest.raises(HTTPException) as exc: + await call + assert exc.value.status_code == 404 + + assert db.users["u-alpha-1"]["status"] == "active" + assert "u-alpha-1" in db.users + _nothing_was_written(db) + + +async def test_the_same_admin_can_still_manage_their_own_tenant( + db: _FakeDB, +) -> None: + """The control every refusal above needs. + + Without it a route that answered 404 for EVERYBODY would pass this file + completely — the tenancy tests would be green and the admin surface would + be dead. Same route, same permissions, one address different. + """ + entry = await members.update_member( + "bob@beta.example", members.MemberPatch(status="suspended"), admin=BEN, + ) + assert entry.status == "suspended" + assert db.users["u-beta-1"]["status"] == "suspended" + assert db.committed == 1 + + +# ════════════════════════════════════════════════════════════════════════════ +# 5. Groups — the third door into another tenant's access +# ════════════════════════════════════════════════════════════════════════════ + +async def test_adding_another_tenants_member_to_your_group_is_a_404( + db: _FakeDB, +) -> None: + """``POST /admin/groups/{slug}/members`` reaches a person by ADDRESS, and + with ``grant_center_access`` it writes a ``feature:center.`` + override on their row as well — so the group shortcut is a second path to + the permission write fenced above.""" + db.seed_group("g-beta-people", "people", organization_id=ORG_B) + + with pytest.raises(HTTPException) as exc: + await groups.add_group_member( + "people", groups.GroupMemberAdd(email="priya@alpha.example"), + admin=BEN, + ) + assert exc.value.status_code == 404 + assert db.group_members == {} + assert db.overrides == {} + _nothing_was_written(db) + + +async def test_a_group_slug_that_exists_in_both_tenants_resolves_to_yours( + db: _FakeDB, +) -> None: + """Group slugs are UNIQUE **per organization** (leak audit S2-5), so + `people` is a legal slug in every tenant at once. Matching on the bare slug + is how one company's roster edit lands in another's group.""" + db.seed_group("g-alpha-people", "people", organization_id=ORG) + db.seed_group("g-beta-people", "people", organization_id=ORG_B) + + await groups.add_group_member( + "people", groups.GroupMemberAdd(email="bob@beta.example", + grant_center_access=False), + admin=BEN, + ) + assert list(db.group_members) == [("g-beta-people", "u-beta-1")] + + +# ════════════════════════════════════════════════════════════════════════════ +# 6. The seam, read as text +# +# Behaviour is the real assertion. These read the source because the shape of +# this particular defect is one a reviewer's eye slides over: a constant that +# looks like configuration, and a helper signature that looks complete. +# ════════════════════════════════════════════════════════════════════════════ + +ADMIN_DIR = Path(_common.__file__).parent +ADMIN_SOURCES = sorted(p for p in ADMIN_DIR.glob("*.py")) + + +def test_the_default_org_slug_is_gone_entirely() -> None: + """⚠️ Not kept as a fallback, because a fallback re-creates the bug. + + The day the caller lookup returns nothing is exactly the day the slug would + hand them `default` again — which is the failure this ticket closes, with a + tenant-shaped comment in front of it. + """ + assert not hasattr(_common, "DEFAULT_ORG_SLUG") + # Comment lines are exempt — the constant's absence is documented where it + # used to live, and a scan that could not tell prose from code would force + # that explanation out of the file it belongs in. + for path in ADMIN_SOURCES: + code = [ + line for line in path.read_text().splitlines() + if not line.lstrip().startswith(("#", "#:")) + ] + assert "DEFAULT_ORG_SLUG" not in "\n".join(code), ( + f"{path.name} still uses it in code" + ) + + +def test_no_admin_route_resolves_an_organization_from_a_literal() -> None: + """The generalisation of the test above: no statement in this package may + reach `organization` by slug at all. Provisioning by slug is legitimate and + lives where it has no caller to derive a tenant from — migration 130 and + ``acb_auth.access._BOOTSTRAP_OWNER_SQL`` — neither of which is on a path a + request can reach.""" + pattern = re.compile(r"FROM\s+organization\s+WHERE\s+slug", re.IGNORECASE) + for path in ADMIN_SOURCES: + assert not pattern.search(path.read_text()), ( + f"{path.name} resolves an organization from a slug" + ) + + +def test_every_call_site_passes_a_caller() -> None: + """The 26 call sites, asserted as a set rather than trusted. + + ``get_org_id(db)`` still parses — the parameter has no default — so a call + site missed during the sweep would be a TypeError at request time on + whichever route nobody exercised, not a failure here. This reads them. + """ + bare = re.compile(r"get_org_id\(\s*db\s*\)") + passing = re.compile(r"get_org_id\(\s*db\s*,\s*(admin|user)\b") + sites = 0 + for path in ADMIN_SOURCES: + body = path.read_text() + assert not bare.search(body), f"{path.name} calls get_org_id without a caller" + sites += len(passing.findall(body)) + assert sites >= 20, f"only {sites} call sites found — did the sweep miss a module?" + + +def test_the_member_lookup_carries_the_tenant() -> None: + """The half that a caller-derived id does not fix. + + Every member-targeted route finds its subject through here. The predicate + is asserted structurally as well as behaviourally because the fake is a + mirror: it reads this clause out of the statement, so the two agree by + construction and only a test that reads the real string can notice it + going missing. + """ + import inspect + + # The statement is assembled from adjacent string literals, so the source + # is joined the way Python joins it before it is read. + source = inspect.getsource(_common.find_member) + sql = " ".join(re.findall(r'"((?:[^"\\]|\\.)*)"', source)) + normalised = " ".join(sql.split()) + assert "FROM app_user WHERE lower(email) = :email" in normalised + assert "AND organization_id = CAST(:org AS uuid)" in normalised + + +def test_the_roster_statement_is_scoped_and_cannot_be_widened() -> None: + """⚠️ Structural because the fake cannot be. + + ``_admin_fakes`` decides which rows a roster statement addresses by reading + the clause out of the statement — which is a stronger mirror than restating + the predicate in Python, and still cannot evaluate SQL. Widening the WHERE + to ``(u.organization_id = :org OR TRUE)`` leaves the clause the fake looks + for exactly where it was, so the behavioural test above stays green while + every organization's roster is served. Measured: that mutant survived the + behavioural suite and is killed here. + + ``OR`` is refused outright rather than pattern-matched. The roster has no + legitimate disjunction, and "an OR appeared in the one query that lists + people" is a thing to look at rather than a thing to parse. + """ + import inspect + + source = inspect.getsource(members.list_members) + sql = " ".join(re.findall(r'"((?:[^"\\]|\\.)*)"', source)) + normalised = " ".join(sql.split()) + assert "FROM app_user u WHERE u.organization_id = CAST(:org AS uuid)" \ + in normalised + assert " OR " not in normalised.upper(), ( + "the roster grew a disjunction — anything OR-ed beside the tenant " + "predicate widens it" + ) + + +def test_the_provisioning_upsert_fences_its_conflict_arm() -> None: + """⚠️ The tenant steal, structurally. + + ``ON CONFLICT (email) DO UPDATE`` without a ``WHERE`` reaches the one row + in the table that a globally-unique email can collide with, which under + D-MT-1 (a) is by definition another tenant's. The ``SET`` must also keep + the existing tenant rather than overwrite it — either half alone is not + enough, so both are read. + """ + sql = " ".join(_common._PROVISION_MEMBER_SQL.split()) + arm = sql.split("DO UPDATE", 1)[1] + assert "COALESCE(app_user.organization_id, EXCLUDED.organization_id)" in arm, ( + "the conflict arm overwrites the existing tenant — that is the steal" + ) + assert re.search( + r"WHERE\s+app_user\.organization_id\s+IS\s+NULL\s+OR\s+" + r"app_user\.organization_id\s*=\s*EXCLUDED\.organization_id", + arm, re.IGNORECASE, + ), "the conflict arm has no tenant fence" From a7c93b1dea9613fda2945050f7aaf24381f521d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 21:45:07 +0000 Subject: [PATCH 16/22] =?UTF-8?q?fix(WS-29):=20UNIQUE=20(lower(email))=20?= =?UTF-8?q?=E2=80=94=20make=20D-MT-1's=20premise=20structural?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit multi_tenancy.md §1.1 rested the whole tenant model on app_user.email being unique, and said so with the word "structurally". It was UNIQUE (email) — byte-exact — while every lookup in this codebase matches lower(email) (R10). The two disagreed. WS-29's S1-1 live run found the gap; I reproduced it directly against Postgres before writing anything: INSERT app_user ('Yan.Probe@Alpha.Example', org P) -- ok INSERT app_user ('yan.probe@alpha.example', org Q) -- ALSO ok -- one human, two rows, two organizations The consequence is worse than a duplicate row. resolve_organization_id — which WS-29b made the answer to "which tenant is this caller", and which S1-1 then made the answer for the entire admin plane — matches on lower(email) and returns whichever row the planner hands back. A person's tenant becomes NON-DETERMINISTIC, and so does everything scoped by it. S1-1 closed it in application code for provision_member, the one write path that could reach it. That guard is right and it stays, but it guards one path: any future insert bypasses it, and "remember to lower-case here" is the class of discipline that produced 137 unscoped tables. Migration 159 is the version that cannot be forgotten — the functional index is created BEFORE the byte-exact constraint is dropped, so there is never a window with neither. Verified: applied twice against live Postgres (idempotent — the second run just notices the constraint is already gone), and the exact probe above is now refused with `duplicate key value violates app_user_email_lower_key`. Checked the live data for pre-existing case-duplicates first; none. Deliberately does NOT normalise stored addresses. created_by, assignee, subject and updated_by are bare address strings across a dozen tables (D-PM-4) and none are foreign keys, so a normalising UPDATE would silently orphan them. Stored casing is presentation; matching is already case-insensitive by R10. The migration says so, and a test asserts no UPDATE is present. Five static assertions in the house style. One of them caught me the same way lib/timeline.ts did earlier: my CONCURRENTLY check tripped on the migration's own comment EXPLAINING why it is not concurrent. A structural test that trips on the prose justifying the rule it enforces is a test somebody deletes — it now strips comments before matching. §1.1 corrected. The decision stands; the claim about its enforcement did not, and the spec now says which is which. Verified: 315 passed across org_access/tenancy/admin, ruff clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/specs/multi_tenancy.md | 19 +++++- infra/postgres/159_app_user_email_case.sql | 72 ++++++++++++++++++++++ tests/unit/test_org_access_control.py | 69 +++++++++++++++++++-- 3 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 infra/postgres/159_app_user_email_case.sql diff --git a/ai-company-brain/specs/multi_tenancy.md b/ai-company-brain/specs/multi_tenancy.md index 70423e6b..b603630f 100644 --- a/ai-company-brain/specs/multi_tenancy.md +++ b/ai-company-brain/specs/multi_tenancy.md @@ -60,9 +60,22 @@ deployment is one organisation."* That comment is about to stop being true. ### 1.1 The one number that decides the cost -`app_user.email` is **globally `UNIQUE`** (`app_user_email_key`). Today a person belongs to -exactly one organization, structurally. Whether that stays true is **D-MT-1**, and it is the -decision the whole retrofit hangs off. +`app_user.email` is **globally unique**, so a person belongs to exactly one organization. +Whether that stays true is **D-MT-1**, and it is the decision the whole retrofit hangs off. + +> ⚠️ **CORRECTED 2026-08-08. This paragraph said "structurally", and until migration 159 that +> was not true.** `app_user_email_key` was `UNIQUE (email)` — **byte-exact** — while every +> lookup in this codebase matches `lower(email)` (R10). The two disagreed, and a live run +> proved the gap real: `Casey@Alpha.Example` and `casey@alpha.example` are two rows, and under +> D-MT-1 they can sit in two organizations. `resolve_organization_id` then returns whichever +> row the planner hands back, so **a person's tenant becomes non-deterministic** — and with it +> everything scoped by that tenant. +> +> Found by WS-29's S1-1 live run, reproduced directly against Postgres, and closed twice: in +> application code for the one write path that could reach it, and structurally by +> **migration 159** (`UNIQUE (lower(email))`, replacing the byte-exact constraint). The +> decision stands — (a) is still the reversible direction — but its enforcement was +> application-level while this document claimed it was structural. It is structural now. ### 1.2 Why Projects is cheaper to retrofit than its size suggests diff --git a/infra/postgres/159_app_user_email_case.sql b/infra/postgres/159_app_user_email_case.sql new file mode 100644 index 00000000..1cc278ac --- /dev/null +++ b/infra/postgres/159_app_user_email_case.sql @@ -0,0 +1,72 @@ +-- 159 — one address is one person, case-insensitively (WS-29, D-MT-1). +-- +-- ⚠️ FOUND BY A LIVE RUN, and it contradicted a claim the multi-tenant design +-- was resting on. +-- +-- `multi_tenancy.md` §1.1 said one person belongs to one organization +-- **structurally**, because `app_user.email` is UNIQUE. That index is +-- `UNIQUE (email)` — BYTE-EXACT — while every lookup in this codebase matches +-- `lower(email)` (house rule R10, case-insensitive on both sides). The two +-- disagree, and the gap is not theoretical: +-- +-- INSERT app_user ('Casey@Alpha.Example', org A) -- ok +-- INSERT app_user ('casey@alpha.example', org B) -- ALSO ok +-- -- one human, two rows, two organizations +-- +-- Reproduced against a real database before this file was written. The +-- consequence is worse than a duplicate row: `resolve_organization_id` — which +-- WS-29b made the answer to "which tenant is this caller", and which S1-1 then +-- made the answer for the whole admin plane — matches on `lower(email)` and +-- returns whichever row the planner hands back. **A person's tenant becomes +-- non-deterministic**, and so therefore does everything scoped by it. +-- +-- WS-29's S1-1 closed this in application code, in the one write path that +-- could reach it (`provision_member`). That guard is correct and it stays, but +-- it is a guard on ONE path: any future insert into `app_user` bypasses it, and +-- "remember to lower-case here" is the class of discipline that produced 137 +-- unscoped tables. The index is the version that cannot be forgotten. +-- +-- **Idempotent**, like every migration in this tree: `IF NOT EXISTS` on the +-- create, and the drop names the constraint it is replacing rather than +-- assuming it is present. + +BEGIN; + +-- The functional index first, so there is never a window with neither. +-- +-- NOT `CREATE INDEX CONCURRENTLY`: that cannot run inside a transaction block, +-- and `app_user` is a table of colleagues — tens of rows, not millions. The +-- brief exclusive lock is cheaper than the two-phase dance and its INVALID-index +-- failure mode. +CREATE UNIQUE INDEX IF NOT EXISTS app_user_email_lower_key + ON app_user (lower(email)); + +-- Only now retire the byte-exact one it subsumes. Dropped rather than kept +-- because two unique indexes on the same column say two different things about +-- the same fact, and the weaker one is the one somebody would later "fix" a +-- constraint violation against. +ALTER TABLE app_user DROP CONSTRAINT IF EXISTS app_user_email_key; + +COMMIT; + +-- ── What this does NOT do ─────────────────────────────────────────────────── +-- +-- It does not normalise existing addresses to lower case. Stored spelling is +-- how a person's name appears in an invitation and in every audit row that +-- names them, and rewriting it would be a cosmetic change with a real cost: +-- `created_by`, `assignee`, `subject` and `updated_by` are bare address strings +-- across a dozen tables (D-PM-4), none of them foreign keys, so a normalising +-- UPDATE here would silently orphan them. +-- +-- Matching is already case-insensitive everywhere by R10, so the stored casing +-- is presentation. This index makes that assumption enforceable rather than +-- merely conventional. +-- +-- ── If this migration FAILS ───────────────────────────────────────────────── +-- +-- It fails only if two rows already differ by case alone, which means the +-- deployment already has the bug and one of the rows is a person's second +-- identity. Do not resolve it by deleting the newer row: check which +-- organization each belongs to first, because under D-MT-1 that is the +-- question, and merging the wrong direction moves somebody between tenants. +-- Verified clean on this checkout before the file was written. diff --git a/tests/unit/test_org_access_control.py b/tests/unit/test_org_access_control.py index 1c603b79..5a4c2df2 100644 --- a/tests/unit/test_org_access_control.py +++ b/tests/unit/test_org_access_control.py @@ -14,9 +14,6 @@ from pathlib import Path import pytest -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient - from acb_auth import ( ASSIGNABLE_SYSTEM_ROLES, CAPABILITIES, @@ -37,7 +34,8 @@ validate_permission, ) from acb_auth.access import SERVICE_ACCESS, legacy_access - +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient # ── Matching ──────────────────────────────────────────────────────────────── @@ -637,3 +635,66 @@ def test_service_principal_runs_any_agent() -> None: ) assert_can_run_agent(user, "anything") # must not raise assert user.has_permission(agent_run_permission("anything")) + + +# ── Migration 159 — one address is one person, case-insensitively ─────────── +# +# WS-29/D-MT-1. `multi_tenancy.md` §1.1 rested the whole tenant model on +# `app_user.email` being unique, and it is — BYTE-EXACT, while every lookup in +# this codebase matches `lower(email)` (R10). A live run proved the gap real: +# `Casey@Alpha.Example` and `casey@alpha.example` are two rows, and under +# D-MT-1 they can sit in two organizations, which makes a person's tenant +# whichever row the planner returns. + +_MIGRATION_159 = Path("infra/postgres/159_app_user_email_case.sql") + + +def test_the_unique_index_is_on_lower_email_not_the_raw_column() -> None: + """⚠️ The claim the tenant model rests on. A `UNIQUE (email)` here agrees + with itself and disagrees with every query in the codebase.""" + sql = _MIGRATION_159.read_text(encoding="utf-8") + assert "CREATE UNIQUE INDEX IF NOT EXISTS app_user_email_lower_key" in sql + assert "ON app_user (lower(email))" in sql + + +def test_the_byte_exact_constraint_is_retired_not_left_beside_it() -> None: + """Two unique indexes on one column state two different things about the + same fact, and the weaker one is what somebody later 'fixes' a violation + against.""" + sql = _MIGRATION_159.read_text(encoding="utf-8") + assert "DROP CONSTRAINT IF EXISTS app_user_email_key" in sql + # Order is load-bearing: the replacement must exist before the original is + # dropped, or there is a window with no uniqueness at all. + assert sql.index("CREATE UNIQUE INDEX") < sql.index("DROP CONSTRAINT") + + +def test_the_migration_is_idempotent_like_every_other() -> None: + sql = _MIGRATION_159.read_text(encoding="utf-8") + assert "IF NOT EXISTS" in sql + assert "IF EXISTS" in sql + + +def test_it_does_not_rewrite_anybody_s_stored_address() -> None: + """⚠️ `created_by`, `assignee`, `subject` and `updated_by` are bare address + strings across a dozen tables (D-PM-4) and none of them are foreign keys, so + a normalising UPDATE here would silently orphan them.""" + sql = _MIGRATION_159.read_text(encoding="utf-8") + assert "UPDATE app_user" not in sql + assert "SET email" not in sql + + +def test_it_is_not_CONCURRENTLY_which_cannot_run_in_a_transaction() -> None: + """`CREATE INDEX CONCURRENTLY` inside `BEGIN` is an error, and this file is + one transaction on purpose. + + Comments are stripped before the check: the migration *explains* why it is + not concurrent, and a structural test that trips on the prose justifying + the very rule it enforces is a test somebody deletes. (The same trap caught + `lib/timeline.ts` earlier in this session, for the same reason.) + """ + statements = "\n".join( + line for line in _MIGRATION_159.read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith("--") + ) + assert "CONCURRENTLY" not in statements + assert statements.count("BEGIN;") == 1 and statements.count("COMMIT;") == 1 From 3c0d1e4b92d4897271fce7b2ecf96e1c6323c51c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 06:12:43 +0000 Subject: [PATCH 17/22] =?UTF-8?q?docs:=20HANDOVER.md=20=E2=80=94=20the=20s?= =?UTF-8?q?tate,=20the=20queue,=20and=20every=20trap=20that=20cost=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written for a coding agent with database access, which is exactly the gap this branch was built against: a scratch Postgres, no production, no deploy, no ability to apply a migration to the real box. §1 branch state. 16 commits ahead, tree clean, PR #399. 2151 backend and 1106 frontend tests green, tsc/ruff/xenon/theme clean. And the first thing the next agent must do: TWO MIGRATIONS EXIST ON NO REAL DATABASE — 158 (organization_id on all 17 pm_* plus the parent trigger) and 159 (UNIQUE (lower(email))). Both idempotent, both applied twice here. Points at schema_migrations as the ledger rather than letting anyone assume, and warns that schema.generated.sql is stale enough to mislead. §2 the house rules, with the reason each exists. The verification protocol — hermetic tests, never bare pytest on the directory, mutation testing with byte-identical revert, and the live Postgres run that found a bug in EVERY ticket on this branch including several where the whole hermetic suite was green. Plus the mirror discipline: fingerprints must be specific not merely present, and read the SQL's own column choices rather than assuming them — both learned by watching a mutant survive. §3 the ticket queue in dependency order, with the two landmines flagged in place: crm_* cannot use `organization_id` because the name is taken by a homonym pointing at a customer company, and gtd_* is scheduled for retirement so keying it is wasted work. §4 what an agent must refuse, and the one gate this branch LIFTED — the ClickUp import was blocked on the pm_* tenant key, which migration 158 delivered. Replaced with the condition that actually remains: apply 158 to the target database first. §5 the traps, which is the part worth reading twice. asyncpg's CAST type inference and AmbiguousParameterError; array_length('{}',1) being NULL when a CHECK only fails on FALSE; a CHECK being unable to read another table; byte-exact UNIQUE against lower() matching; LIKE metacharacters; midnight UTC and DST rounding — and that both date traps are only behaviourally testable in some timezones, so they need structural pins too; git reset renaming so a self-rewriting script runs its old steps and exits 0; a structural test tripping on the prose that justifies its own rule; and parallel mutation harnesses making verification unreliable. Corrections in these documents are marked ⚠️ and kept rather than erased, including two of mine that were wrong in writing. A document that quietly edits its mistakes teaches nobody where the traps are — which is the whole point of §5. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/HANDOVER.md | 299 ++++++++++++++++++++++++ ai-company-brain/specs/multi_tenancy.md | 13 +- ai-company-brain/work_plan.md | 14 +- 3 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 ai-company-brain/HANDOVER.md diff --git a/ai-company-brain/HANDOVER.md b/ai-company-brain/HANDOVER.md new file mode 100644 index 00000000..59220202 --- /dev/null +++ b/ai-company-brain/HANDOVER.md @@ -0,0 +1,299 @@ +# Handover — branch `claude/paca-research-task-management-a1f6zd` + +> **Written 2026-08-08 for a coding agent with database access.** Everything here was built in +> a sandbox with a *scratch* Postgres and **no access to production, no deploy, and no ability +> to apply a migration to the real box**. That is the gap you are picking up. +> +> Read §1 and §2 before touching anything. §3 is the ticket queue. §4 is what only the owner +> may decide. §5 is the accumulated list of traps — it is the most valuable part of this +> document and it will save you a day each time you read it. + +--- + +## 1. Where the branch is + +**16 commits ahead of `main`, tree clean, everything pushed.** Open PR **#399**. + +| Verified on this branch | | +|---|---| +| Backend tests | **2151 passed**, 11 skipped | +| Frontend tests | **1106 passed** (green in 4 timezones) | +| `tsc --noEmit` | clean | +| `ruff` / `xenon` | clean on all changed files | +| Theme conformance | green | + +Two workstreams landed: + +**WS-27 (Projects) — the ClickUp parity backlog in `specs/project_management_app.md` §11.2 is +CLOSED.** Tickets a, b, d, e, f, i–t are built. The app has hierarchy, statuses-as-data, +custom fields, tags, bulk edit, recurrence, dependencies, attachments, notifications, filters +and saved views, a personal lens, a board, a list, a calendar, a Gantt timeline with drawable +dependencies, and a ⌘K search palette. + +**WS-29 (multi-tenancy) — started, and deliberately not finished.** See §3. + +### ⚠️ 1.1 The first thing to do, before any ticket + +**Two migrations exist on this branch and are on no real database:** + +- `infra/postgres/158_projects_tenancy.sql` — `organization_id NOT NULL` on all 17 `pm_*` + tables, plus a parent-consistency trigger. +- `infra/postgres/159_app_user_email_case.sql` — `UNIQUE (lower(email))` on `app_user`. + +Both are idempotent and both were applied twice against a live Postgres 16 here. **But this +sandbox's database is not yours**, and `schema_migrations` (migration 153) is the ledger — +check it before assuming anything about what the box has: + +```sql +SELECT filename FROM schema_migrations ORDER BY filename DESC LIMIT 15; +``` + +⚠️ **Migration 158 backfills to the organization with `slug='default'` and fails loudly if it +is absent.** That is deliberate — guessing a tenant is worse than stopping. If your database +has no such row, migration 130 seeds it. + +⚠️ **`schema.generated.sql` is stale** — it predates migration 146 and knows about none of the +`pm_*` tables. Do not read it as truth; read the migrations, or the live database. Regenerating +it needs a database with every extension available (this sandbox lacked `vector`, so a dump +from here would have been *worse* than the stale file). + +### 1.2 The deploy path is broken and that is not fixed + +`specs/deploy_delivery_path.md` — WS-25. GitHub's packets do not reach the VPS; deploys +alternate 4-minute successes with 54-minute timeouts. **Merging does not ship.** D1 (extracting +and shellcheck-cleaning the deploy script) is done; the delivery mechanism itself is owner-gated +and untouched. Assume nothing you merge reaches the box until somebody switches it. + +--- + +## 2. House rules — non-negotiable + +These are not style preferences. Each one exists because it caught a real defect in this +codebase, most of them during this branch's work. + +### 2.1 The verification protocol + +1. **Hermetic tests first.** Route functions called directly, `_get_db` monkeypatched onto each + SUT submodule, against the shared fake. Never a `TestClient`. +2. ⚠️ **Never run `uv run pytest tests/unit/` bare** — whole-directory collection hangs against + a live DB. **Name the files**, or use `-k`. +3. **Mutation testing on every guard you add.** Mutate it, prove the suite goes red, revert + **byte-identically** (`diff -q`). A mutant that survives means the test asserts nothing — + *strengthen the test, do not accept the pass.* On this branch three mutants survived their + first pass and every one of them exposed a test that was checking nothing. +4. **A live Postgres run, always.** Start: + ``` + su postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D -o '-k /var/tmp -p 55432' start" + ``` + DSN: `postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432`. + Drive the **real endpoint functions**, not a mock. Patterns to copy live in the scratchpad as + `live_ws27*.py` / `live_ws29.py`. + + **This found a bug in every single ticket on this branch — including several where the + entire hermetic suite was green.** It is not optional and it is not a formality. +5. **Gates:** `uv run ruff check ` and + `uv run xenon --max-absolute F --max-modules F --max-average B `. + Frontend: `npx tsc --noEmit`, `npx vitest run`, and **`npx vitest run src/lib/theme/`** + before pushing. + +### 2.2 The fake is a MIRROR, and a mirror can only agree with itself + +`tests/unit/_projects_fakes.py`. It reads the *statement text* to decide which rows a clause +addresses. **Every clause must be mirrored only when the statement carries it.** A fake that +re-implements a predicate in Python and applies it unconditionally passes against a route that +dropped the clause entirely — which is the whole defect class the file exists to prevent. + +Two corollaries learned the hard way on this branch: + +- **Fingerprints must be specific, not merely present.** `"AS blocker"` also matches + `AS blockers`. Dispatching on a substring that appears in a *different* statement silently + routes the wrong query. +- **Read the SQL's own column choices; never assume them.** A mirror that hard-coded which end + of a `blocks` link was the blocker let a mutant swap the SQL's two aliases — every arrow + drawn backwards — with the whole suite green. + +### 2.3 Documented rules that bite + +- **R1** — resolve the next free migration number **at build time** (`ls infra/postgres/`), + never from a spec. +- **R3** — identity from the authenticated context only, never a request parameter. +- **R5** — **404, never 403.** "Not yours" and "no such thing" must be indistinguishable. +- **R10** — case-insensitive email on both sides. +- `DESIGN_SYSTEM.md` is a contract: never write a colour, never + `import … from "lucide-react"` (use ``), never hand-roll a control. + +--- + +## 3. The ticket queue + +Dependency order. Everything here is agent-safe to **build**; the owner gates in §4 are about +*executing* against production. + +### WS-29c — enforce the boundary (blocked on D-MT-2) + +The column and the application predicate exist for `pm_*`. What enforces isolation for +everything else is **D-MT-2, still open** (§4). The recommendation on record is Postgres RLS, +because it is the only option where the *absence* of code is safe rather than a leak — and +given 123 unscoped tables, absence of code is the failure mode this system actually has. + +**Do not start until D-MT-2 is answered.** Building the wrong enforcement is a rewrite. + +### WS-29d — the remaining 123 tables + +`tests/unit/test_tenancy_boundary.py` holds the frozen list and fails any **new** unscoped +table. Work by family; the migration pattern is `158_projects_tenancy.sql` and it is worth +copying wholesale, including the trigger. + +⚠️ **Before `crm_*`: the column name is already taken.** `crm_activities`, `crm_contacts` and +`crm_deals` have an `organization_id` that references **`crm_organizations`** — a customer +company. Scoping the CRM needs a rename or a different name. That is a decision, make it +explicitly. + +⚠️ **Before `gtd_*`: those tables are scheduled for retirement** (WS-27h, D-PM-6). Adding a +tenant key to a table you are about to delete is wasted work — do WS-27h first or skip the +family knowingly. + +**Split the baseline while you are here.** The audit's §5 proposes three sets and the argument +is right: `NEVER_SCOPED` (`organization`, `schema_migrations`, `feature_catalog`), +`DEPLOYMENT_GLOBAL` (each needing a named decision), `NOT_YET_SCOPED` (the rest). "Deliberately +global" is a decision, and hiding it among "not done yet" is how it gets made by accident. + +### The leak backlog — `specs/multi_tenancy_leak_audit.md` + +14 findings with `file:line` citations, ranked by blast radius. **S1-1 and S1-4 are FIXED** on +this branch. The rest are open: + +| | Finding | Note | +|---|---|---| +| **S1-2** | One set of LLM and integration credentials for the whole deployment | Needs a decision — see §4 | +| **S1-3** | Global event bus: tenant A's event fires tenant B's workflow, which may write tenant A's task | Needs the workflow tables keyed first | +| S2-5 | `org` means "everybody in the deployment" in rooms and session authority | | +| S2-6 | An org-visible Custom App is visible to every tenant, and carries its data | | +| S2-7 | The Action Broker queue is global, and approving executes | | +| S2-9 | Shared agents have one workspace and one blob partition | The instance vocabulary has `u:`/`t:` but no `o:` | +| S3-10 | Global tool/plugin registries reach every tenant's agents | | +| S3-11 | Public webhook receivers authenticate a *deployment*, not a tenant | | +| S3-12 | Jobs that run with no `X-User-Email`, and therefore no tenant | | +| S3-13 | Enumeration surfaces without a tenant | | +| S3-14 | One sign-in domain for the deployment | | + +The audit's **§3 (SAFE, with reasons)** is as valuable as the findings — it stops you +re-checking closed paths. Notably: **there is no object storage at all**; attachments are local +disk, `uuid4`-named, never served by path. + +The audit's **§4 (could not determine)** is honest ground nobody has covered: ingestion consumer +drain semantics, Mem0/graphiti partitioning, `custom_api_definitions`, the meeting-bot chain, +and the frontend. + +### WS-27h — retire `gtd_items` + +Sequenced after WS-27e (built). D-PM-6 makes `pm_tasks` the one task store; `gtd_items` is a +lens over it now, not a copy. This is a destructive data move — treat it accordingly, and note +it interacts with WS-29d as above. + +### WS-27g — cutover and ClickUp retirement + +🔴 Owner-gate end to end. See §4. + +--- + +## 4. Owner decisions and gates — an agent must refuse these + +Registered in `work_plan.md` §6. Do not execute; propose and stop. + +**Open decisions:** + +- **D-MT-2 — where is isolation enforced?** RLS / application predicate / schema-per-tenant. + Options costed in `specs/multi_tenancy.md` §3. **Blocks WS-29c.** +- **D-MT-3 — `organization_id` on the row, or through a parent?** Agent-proposed: on the row. + Already implemented that way for `pm_*`. +- **S1-2 — do LLM and integration credentials go per tenant?** `provider_keys.provider` is the + primary key, so today one deployment has one set. This is a security *and* a billing + question, not a config nicety. +- **The sign-in queue is genuinely shared.** `access_request` has no tenant column and cannot + straightforwardly have one — an address knocking at the door has no organization yet. + Admin B can see and **deny** admin A's pending knock: a cross-tenant DoS on onboarding. + Approve is now fenced; deny cannot be without a routing rule (domain? invite token?). + +**Execution gates:** + +- Running either ClickUp import endpoint against production, and confirming a Space→Center + mapping (D-PM-10). ⚠️ **The multi-tenant block on this is now LIFTED** — migration 158 + keyed the `pm_*` tables, which was the reason to wait. +- Enabling the WS-27c outbound push (needs BO-1a + BO-1b). +- The WS-27g cutover and ClickUp token revocation. +- Granting `feature:projects` or `data:org:read` to any real member on the live box. +- Flipping `ACTION_BROKER_ENFORCE`, `INGESTION_CONSUMER`, `CRM_ZOHO_SYNC`. ⚠️ The last two + **write unscoped rows unattended** — the same hazard as the ClickUp import, without a button. + +--- + +## 5. Traps — every one of these cost real time + +**asyncpg** + +- It infers a bound parameter's type from a surrounding `CAST(...)` and then **refuses to encode + a mismatched Python type**. Binding a `str` to `CAST(:x AS timestamptz)` fails before the + query reaches the database. Parse to a `datetime` on the Python side. +- A bare `:param IS NOT NULL` with no column to infer from raises + `AmbiguousParameterError: could not determine data type of parameter $1`. **Cast it + explicitly.** A Python fake has no type system, so every hermetic test passes. +- No codec for a bare `dict` — JSONB must be serialised and cast. + +**SQL** + +- `array_length('{}', 1)` is `NULL`, and a `CHECK` only fails on `FALSE`. A constraint written + this way passes the row it exists to reject. Use `coalesce(…, 0)`. +- Implicit-comma `FROM` plus a `LEFT JOIN` leaves the earlier table out of scope for the join's + `ON` clause. +- `array_agg(DISTINCT …)` sorts by its own expression — it will silently alphabetise a list you + meant to keep in order. +- A `CHECK` **cannot read another table**; Postgres refuses the subquery. Cross-table invariants + need a trigger. +- `UNIQUE (email)` is **byte-exact**. If your code matches `lower(email)`, the two disagree and + one human becomes two rows. (Migration 159.) + +**LIKE / search** + +- `_` and `%` are metacharacters. Unescaped, searching `task_id` also matches `taskXid`. Escape + the backslash **first**, or you double the escapes you just introduced. + +**Dates and timezones** + +- `new Date("2026-08-07")` is **midnight UTC** — the 6th anywhere west of Greenwich. Work in + `YYYY-MM-DD` keys for anything that means a *day*. +- Millisecond arithmetic across a DST transition is 23 or 25 hours; an unrounded division lands + a fraction of a day off **permanently**. Round. +- ⚠️ Both of the above are only *behaviourally* testable in some timezones. CI runs one. **Pin + them structurally as well**, or the mutation that reintroduces them survives forever. + +**Shell / deploy** + +- `git reset --hard` **renames**, so a running script keeps its old inode: all its steps run, + from the *old* version, against the *new* tree, and it **exits 0**. Two of the three + self-rewrite failure modes are silent. + +**Testing** + +- A structural test that greps its own module's source will trip on **prose explaining the + rule it enforces**. Strip comments first. (This bit twice on this branch.) +- Running two mutation harnesses in parallel makes verification unreliable — one agent's + temporary mutation shows up as another's failure. If you fan out, use worktree isolation. + +--- + +## 6. Where to read next + +| Document | What it owns | +|---|---| +| `work_plan.md` | Every workstream, its status, and §6's owner-gate registry | +| `specs/multi_tenancy.md` | The measured tenant state, D-MT-1/2/3, the sequence | +| `specs/multi_tenancy_leak_audit.md` | 14 leak findings, the SAFE list, the unknowns | +| `specs/project_management_app.md` | WS-27 end to end; §11 is the parity story per ticket | +| `specs/deploy_delivery_path.md` | Why merging does not ship, and D1's measured evidence | +| `specs/paca_pm_research_2026-08.md` | The Paca patterns adopted, and the ones refused | + +**Corrections already made in these documents are marked ⚠️ and kept rather than erased** — +including two of mine that were wrong in writing (the tenant-scoped table count, and a claim +that one-person-one-organization held structurally when it did not). If you find another, mark +it the same way. A document that quietly edits its mistakes teaches nobody where the traps are. diff --git a/ai-company-brain/specs/multi_tenancy.md b/ai-company-brain/specs/multi_tenancy.md index b603630f..409dffea 100644 --- a/ai-company-brain/specs/multi_tenancy.md +++ b/ai-company-brain/specs/multi_tenancy.md @@ -93,8 +93,11 @@ tables. ## 2. What this means for the ClickUp import — read this first -🔴 **Do not run `POST /projects/import/clickup` against production until the `pm_*` tenant key -lands.** This is the one place where the multi-tenant plan collides with work already queued. +~~🔴 **Do not run `POST /projects/import/clickup` against production until the `pm_*` tenant +key lands.**~~ — **SATISFIED 2026-08-08 by migration 158**, which keyed all seventeen tables. +Kept rather than deleted because the reasoning is what generalises, and because **one condition +replaced it: migration 158 has to be applied to the target database first.** It is on no real +box yet — the deploy path is broken (WS-25), so nothing on this branch has shipped. The import is an owner gate (`work_plan.md` §6 (a)) and is the next thing WS-27 wants. Running it now writes a real ClickUp workspace — hundreds of tasks, their activities, attachments and @@ -203,11 +206,11 @@ between now and then is another backfill. | | Ticket | Depends on | |---|---|---| -| 1 | **WS-29a** — answer D-MT-1; `organization_id` on the 17 `pm_*` tables while they are still empty | D-MT-1 | -| 2 | **WS-29b** — the tenant predicate in `_VISIBLE_PROJECTS_SQL` + `Visibility`; the `subject='org'` literal becomes org-relative | WS-29a | +| 1 | ~~**WS-29a**~~ ✅ **BUILT** — migration 158. ⚠️ The tables were *nearly* empty, not empty; it backfills | ~~D-MT-1~~ ✅ (a) | +| 2 | ~~**WS-29b**~~ ✅ **BUILT** — plus three leaks it exposed, incl. `/assigned-to-me` having no visibility clause at all | ~~WS-29a~~ ✅ | | 3 | **WS-29c** — RLS policies and the connection-level GUC, behind a flag, off | D-MT-2 | | 4 | **WS-29d** — the remaining 120 tables, by family, largest blast radius first | WS-29c | -| — | **WS-27g's ClickUp import** | **after WS-29a** | +| — | **WS-27g's ClickUp import** | ~~after WS-29a~~ ✅ **unblocked** — but apply 158 to the target DB first | **WS-29a is the only urgent one**, and only because of the import. The rest can proceed at whatever pace the product needs. diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index d49188cc..a102c11d 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -148,7 +148,7 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`. | WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. | | WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. | | **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT + DEPLOYED** · ✅ **d read half BUILT + DEPLOYED** · ✅ **D2 = d-email BUILT 2026-08-07** (branch `ws-26d-email-timeline`, merged) · 🟢 **d-autolead, d-write dispatchable** · ✅ **D1 = f BUILT 2026-08-07 (branch `ws-26f-pipeline-truth`, NOT run against prod)** · ✅ **D3 = g BUILT 2026-08-07 (branch `ws-26g-reports`, no migration)** · 🟢 **DEMO CRITICAL PATH (owner-directed 2026-08-07, spec §9.0): ~~D1 f~~ (∥ D2 d-email) → ~~D3 g~~ → D4 d-write → D5 d-autolead** · 🟡 **h/i/e deferred past the demo; i spec-thin** | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **migration 144 applied on prod and `/crm` live as of 2026-08-06**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Measured 2026-08-06: mig 145 is applied on prod and the BACKFILL HAS RUN — 737 orgs / 1,189 contacts / 1,516 leads / 551 deals / 1,909 notes, zero dirty rows, zero unmatched owners; the §7.1 pre-flip curl confirmed the tenant honors RFC-1123 `If-Modified-Since` (304). The PUSH direction has still never run: `CRM_ZOHO_SYNC` ships OFF, nothing has ever written the live Zoho tenant, and enabling the flag or hand-running a push cycle against prod stays OWNER-GATE §6.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Deployed:** migrations 144 and 145 are applied on prod as of 2026-08-06 and `/crm` is live, so live rendering, drag persistence and deep links are owner-verifiable now) · **d** integrations — **audited 2026-08-06 GO-NARROWED and the narrowed slice is BUILT** (branch `ws-26d-agent-crm`): `apps/agents/agent-crm/` (`crm-assistant`, MAF, four READ tools over the existing `/crm` routes carrying the caller's `X-User-Email`, read-only enforced at the transport by a GET-only method allowlist) registered in `_KNOWN_AGENTS` + `_AGENT_REGISTRY` + `agent_registry.json`, plus `"crm"` added to the WhatsApp `_KNOWN_SYSTEMS` allowlist **parse-only** (nothing writes `wa_contacts.entity_ref`, the `crm` context block stays `None`, both pinned by test). **The three held-back items are now DISPATCHABLE — their doc blockers (B3/B4/B5/B7) were closed 2026-08-06 in `crm_app.md` §9.1-§9.3, every anchor read off `origin/main` rather than recalled:** **WS-26d-email** (the timeline join is CALLER-scoped, never record-scoped — it reuses the email app's `_account_scope` predicate, copied into `routes/crm/` rather than imported per D-CRM-4, joins by thread not message, inbound `from_address` only, and needs a new address index at the next free migration number) · **WS-26d-autolead** (hook = `routes/email/scheduler_hooks.py::process_new_mail`, the one seam scheduler+manual+webhook all funnel through; the per-message rules loop was considered and REJECTED because a classifier outage there double-fires and history backfills never reach it; unknown-sender test mirrors `_maybe_block_cold`, colleague suppression via `is_own_mail`) · **WS-26d-write** (`request_confirmation` at the top of each tool, fail-closed, no `non_interactive_default="approve"`; `@_annotate_risk` is the shared annotation convention and is NOT enforcement; the Action Broker covers the Zoho push, not the native write — the two fail in OPPOSITE directions and are not interchangeable). The push-queue question is CLOSED — **D-CRM-9 (owner, 2026-08-06): agent-originated writes queue for Zoho exactly like human ones** (🟡 remainder; the flip stays OWNER-GATE) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. **Pipeline blueprint added 2026-08-07 (spec §5.1) after the owner's first live board session found lanes out of order and imported stages at 0% probability — root cause: the importer appends unseen Zoho stages past the seeds at probability 0, and 144's seeds renamed Zoho's defaults so name-match missed them; the admin API that could fix it is headless.** New tickets: **f** pipeline truth + settings UI — **BUILT 2026-08-07** (branch `ws-26f-pipeline-truth`, **no migration**: 144 already carried `position`, `probability`, `type`, `closed_at` and `expected_close_date`). `routes/crm/stage_metadata.py` = `POST /crm/import/zoho/stages`, floor `admin:access:manage`, **dry-run by default** and `?apply=true` to write; **>1 pipeline STOPS before the DB is opened** (D-CRM-11); f4's `closed_at` proxy is a direct UPDATE that bypasses `mark_dirty_on_update`, asserted statically against the statement text AND the module's call graph because the shared fake writes only what a SET clause names. Two settings readers on the Zoho read client with `ZohoScopeError`/`ZohoApiVersionError` so **no-scope, no-data and no-such-endpoint are three different reported outcomes** — and no-scope is the *expected* first answer, since the tenant's refresh token was never minted with `ZohoCRM.settings.*`. D-CRM-10's clamp landed in `admin.py::_validate_status` reading the ROW rather than the payload (a PATCH naming only `type`, or only `probability`, contradicts the rule only in combination with what is stored). `?tab=settings` is the headless-API fix and needs no Zoho token at all. **Nothing has been run against the tenant — dry run included.** · **g** forecast & funnel reports off `crm_status_changes` — **BUILT 2026-08-07** (branch `ws-26g-reports`, **no migration**: 144's `crm_status_changes` already carried every column). `routes/crm/reports.py` = four read-only endpoints (`/crm/reports/{pipeline,funnel,win-loss,owners}`) on the shared gated router, plus `?tab=reports`. **`WEIGHTED_SQL` lifted into `core.py`** (pipeline re-exports it) so the forecast formula has ONE definition, and `core.status_wire` absorbed two duplicate status projections instead of gaining a third. **The cross-language parity mechanism is minted here, not inherited** — the `priority.ts ⟷ priority.py` "precedent" is two hand-kept mirrors joined by a comment with no shared fixture anywhere: `tests/fixtures/crm_weighted_parity.json` (new directory) is read by BOTH pytest (through the emitted SQL, whose expression `_crm_fakes._WEIGHTED_SUM_RE` parses out of the statement text) and vitest (through `board.ts::weightedDeal`/`weightedRows`). ⚠️ **The funnel is defined against what the log RECORDS, not what its name suggests**: `crm_status_changes` logs transitions only, so all 551 imported deals have zero rows and "entered" is a VISITED-SET union (`from_status`, `to_status`, and the deal's current stage) — a `to_status` count reports an empty funnel for the whole live board; dwell is grouped by **`from_status`** (the stage being LEFT, the opposite key from the naive reading); the log stores NAMES, so a renamed lane orphans its history and orphans are REPORTED in `unmatched`, never dropped; and NULL `closed_at` — every imported closed deal until f4's owner-gated backfill runs — falls outside the trailing window, with the count reported so a 0% win rate is explicable rather than mysterious. The lost-reason breakdown carries a NAMED unattributed bucket: the earlier "complete by construction" claim is FALSE, since the importer bypasses both gates and `lost_reason_id` is `ON DELETE SET NULL`. **No `GROUP BY` is emitted** — the ticket asked for the choice to be stated: per-key aggregates in `get_pipeline`'s shape, because the weighted expression binds the lane's own default as `:stage_probability` and a grouped statement would stop BEING the expression the fixture and the fake read. 47 hermetic cases + 20 vitest + the 14-row shared fixture on both sides, 5 mutants red. **Two findings:** the `entity_type='deal'` mutant initially SURVIVED (the funnel keys through deal ids, so a lead row is excluded twice over) — closed with a row stamped `lead` against a DEAL's id, which is realistic precisely because `entity_id` has **no FK**; and `_crm_fakes`' `lower(col) = :param` reader matched a NULL column, which SQL never does, making the unassigned-owner bucket count rows its own aggregate never summed · **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views (🟡 spec-thin, audit-narrow first). **Demo path (2026-08-07, spec §9.0): full chain + all gates intact, tickets re-sequenced not thinned; f gained f4 — imported won/lost deals have no `closed_at` (importer never stamps it), backfilled from Zoho `Closing_Date` as a labeled proxy via a direct UPDATE that MUST bypass dirty-marking or ~500 no-op pushes queue for the live tenant.** | -| **WS-29** | **Multi-tenancy — isolating organizations** *(minted 2026-08-08)* | `specs/multi_tenancy.md` | 🔴 **D-MT-1 OWNER-ANSWER REQUIRED** · 🟢 ratchet in place | **Measured 2026-08-08 — and CORRECTED the same day: 143 app tables, **THREE** carry a real tenant key (`app_user`, `org_group`, `org_role`, all `REFERENCES organization`), 140 carry none — including all 17 `pm_*`.** ⚠️ This row first said six: the three `crm_*` tables carry a column *spelled* `organization_id` that references `crm_organizations`, a CUSTOMER COMPANY, not the tenant. Found by the leak audit, verified against `pg_constraint`. Consequences: the column name is taken (scoping `crm_*` needs a rename, decide before WS-29d), and the ratchet matched on column NAME so a homonym pointing anywhere passed silently — it now matches the FK target. An `organization` table has existed since migration 130 with one seeded row (`slug='default'`) and `app_user.organization_id`; tenancy was started and never carried past access control and the CRM. ⚠️ **`app_user.email` is globally UNIQUE, so today one person = one organization structurally — D-MT-1 asks whether that stays true, and everything else is downstream of the answer.** Projects is cheaper than its size suggests: 128 `FROM`/`JOIN` references to `pm_*` but **one** closure query (`_VISIBLE_PROJECTS_SQL`), so the retrofit is a column on 17 tables, a predicate in one query, and one line in the `Visibility` resolver. 🔴 **Blocks WS-27's production ClickUp import** (§6 gate (a)): importing a real workspace into 17 unscoped tables turns a one-line default on empty tables into a backfill on live rows. `tests/unit/test_tenancy_boundary.py` freezes the 137 and fails any NEW unscoped table — a ratchet, not a demand for the retrofit. Sequence in spec §5: **WS-29a** (`pm_*` key, urgent, gates the import) → **b** (tenant predicate) → **c** (RLS behind a flag) → **d** (the remaining 120 by family). | +| **WS-29** | **Multi-tenancy — isolating organizations** *(minted 2026-08-08)* | `specs/multi_tenancy.md` | ✅ **a + b BUILT 2026-08-08** (migration 158: `organization_id` on all 17 `pm_*` + a parent-consistency trigger; tenant predicate in the one visibility closure) · ✅ **S1-1 + S1-4 leaks FIXED** · ✅ **migration 159** `UNIQUE (lower(email))` · ✅ D-MT-1 answered (a) · 🔴 **D-MT-2 OPEN — blocks c** · 🟡 d = 123 tables · 🟡 10 leak findings open | **Measured 2026-08-08 — and CORRECTED the same day: 143 app tables, **THREE** carry a real tenant key (`app_user`, `org_group`, `org_role`, all `REFERENCES organization`), 140 carry none — including all 17 `pm_*`.** ⚠️ This row first said six: the three `crm_*` tables carry a column *spelled* `organization_id` that references `crm_organizations`, a CUSTOMER COMPANY, not the tenant. Found by the leak audit, verified against `pg_constraint`. Consequences: the column name is taken (scoping `crm_*` needs a rename, decide before WS-29d), and the ratchet matched on column NAME so a homonym pointing anywhere passed silently — it now matches the FK target. An `organization` table has existed since migration 130 with one seeded row (`slug='default'`) and `app_user.organization_id`; tenancy was started and never carried past access control and the CRM. ⚠️ **`app_user.email` is globally UNIQUE, so today one person = one organization structurally — D-MT-1 asks whether that stays true, and everything else is downstream of the answer.** Projects is cheaper than its size suggests: 128 `FROM`/`JOIN` references to `pm_*` but **one** closure query (`_VISIBLE_PROJECTS_SQL`), so the retrofit is a column on 17 tables, a predicate in one query, and one line in the `Visibility` resolver. 🔴 **Blocks WS-27's production ClickUp import** (§6 gate (a)): importing a real workspace into 17 unscoped tables turns a one-line default on empty tables into a backfill on live rows. `tests/unit/test_tenancy_boundary.py` freezes the 137 and fails any NEW unscoped table — a ratchet, not a demand for the retrofit. Sequence in spec §5: **WS-29a** (`pm_*` key, urgent, gates the import) → **b** (tenant predicate) → **c** (RLS behind a flag) → **d** (the remaining 120 by family). | | **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e + i BUILT 2026-08-06 · j + k + l + m + n + o + p + s BUILT 2026-08-07 · q + r + t BUILT 2026-08-08 — the ClickUp parity backlog (§11.2) is now CLOSED** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced · ✅ **t BUILT 2026-08-08** (D-PM-11 + D-PM-12 answered, gate (e) cleared) | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g — and n is now BUILT (2026-08-07), so that dependency is SATISFIED**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end. g itself stays 🔴 OWNER-GATE for its own reasons (§6); this changes the prerequisite, not the gate · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store).. **j BUILT 2026-08-07** (mig `152_projects_notifications.sql`, `routes/projects/notifications.py`, the header bell + mention picker; 39 hermetic + 27 vitest cases, 10 mutants red): closes §11.2's second gap — *"assignment is silent"*. **Three rules decide who hears**, each the whole reason for a rule: never the actor (a bell that pings you about your own click gets muted, and a muted bell notifies nobody about anything); never an agent (they are handed work by the WS-27f dispatch sink, so a row addressed to one sits unread forever inflating a badge nobody can clear — enforced in Python AND by a CHECK); and **never somebody who cannot open it**, which is the security property: the notification carries the task's TITLE, so delivering one outside the grant closure leaks it and lands them on a 404. That third rule needed `resolve_visibility_for`, which answers for a THIRD PARTY — `resolve_visibility` reads a `UserContext` and the recipient of a mention has no request in flight — by reading the tables `/auth/me` reads and handing them to the **real** `build_access`, so wildcards and allow/deny overrides resolve identically on both paths. Notifications are written **inside the transaction**, not emitted on the bus: `emit` swallows failures by construction so a broken workflow can never fail a task edit, which is right for agent dispatch and wrong here. A mention is an **address, not a name**, because 148 dropped `UNIQUE(name)` — `@Priya` has no answer. **Two bugs found on the way in, both shipping at the time:** every project-task file upload was answering **422** (`ACTIVITY_TYPES` never learned 150's `attachment`; all 25 attachment tests passed because they monkeypatch `record_activity` — the seam under test was mocked out), fixed with two tests that READ the migrations; and `/projects?task=` did nothing, though the People Center has linked there since WS-28b. **WS-27b's UI BUILT 2026-08-07** (`components/ImportClickUp.tsx`, `lib/importPlan.ts`; 18 vitest cases, 3 mutants red): the importer shipped with WS-27b and was **unreachable from the product** — the empty state named "import a ClickUp workspace" and no control anywhere did it, so a new install stayed empty and the only route to real data was curl. Three steps, only the last of which writes: Preview (`/plan`, reads the tenant) → Dry run (`dry_run:true`, exercises the flattening) → Import. **The mapping stays the owner's act (D-PM-10)** and the UI is built so it stays one: the suggestion is pre-filled and shown beside its confidence IN WORDS ("a guess — check it", not `0.45`, because a bare number invites acceptance without looking), and a CONFIRMED mapping always beats a fresh suggestion so a re-run never silently re-maps a Space somebody already ruled on. Unmapped Spaces are a notice rather than a blocker, matching the importer. ⚠️ The gate is unchanged and is now exactly one click: building this was agent-safe, pressing Import is the owner's act, and no agent has run either endpoint against production. **The Tasks-app mirror path BUILT 2026-08-07** (`routes/projects/import_tasks.py`, `POST /import/from-tasks`; 43 hermetic cases, 7 mutants red) — owner-directed: *"just show up all the data that is there in the Tasks app inside the Projects app"*. A SECOND importer rather than a flag: the ClickUp one needs a live token, spends LLM budget and demands a Center mapping BEFORE anything is written, which is backwards for "show me my work today". This reads `gtd_projects`/`gtd_items` — the mirror already on the box — so no API call, no token, no model spend, and it works when the connector is stale. One named department, with the real ClickUp shape beneath it — **Space → Folder → List** rebuilt as projects, each carrying its own `clickup_id`/`clickup_kind`, so promoting a Space node is how the department split happens later. **Verified against a real Postgres** (full migration set + seeded mirror), which found THREE defects the hermetic suite could not: `gtd_projects.space_id` is LOCAL-only so the Space was always NULL; `pm_projects` has no `clickup_snapshot` column, so the first real click would have 500'd (this shipped in #393 and was fixed before anybody pressed it); and the preview under-counted 4 vs 7 because container nodes were only tallied on the write path. The root IS org-granted (same act as `create_node`, narrower than bulk-granting a tenant). Pinned: nothing outside `pm_*` is written; only `source <> 'LOCAL'` rows are read (a personal capture must not be published to a shared board); the provider's own status names are kept; an orphaned task is COUNTED, not dropped. **Mutation found a real gap**: deleting `dry_run` from the write guards left every test green, because on a FIRST dry run `root_id is None` blocks the write anyway — on a SECOND one the department exists and `dry_run` is the only protection. That is the realistic case (preview → import → preview again) and it now has its own test. **k BUILT 2026-08-07** (`routes/projects/filters.py`, `lib/grouping.ts`, `components/FilterBar.tsx`; 34 hermetic + 24 vitest cases, 13 mutants red, and 23 checks against a REAL Postgres) — closes §11.2's third gap, the one whose name was the sentence *"my open bugs in Ops, grouped by assignee"*. **ONE filter builder serves both the list endpoint and saved views**, because a saved view is nothing but a stored set of these filters and two implementations would drift — a *saved* view showing a different set than the same filters typed by hand is the one thing it may not do, so a test compares the two outputs directly. **Every filter is a WHERE clause**: paging happens in SQL, so a filter applied in Python after `LIMIT` returns short pages, and *"page 2 is empty but there are 40 more"* is a bug people work around for months instead of reporting. **`overdue` means past due AND still open** — a finished task with a past due date is done, and permanent red is how a board teaches people to ignore red. **An unknown category is a 422 naming the five real ones**, not an empty board a client reads as "this project is empty". Unknown config **keys** are DROPPED while a bad **value** falls back, because those are different failures: a view is a preference written by an older client, so refusing one over an unrecognised key would make every deploy a migration of everybody's saved views, whereas rendering still has to produce something. On the board, **a task with two assignees appears in BOTH columns** (it is both people's work; picking one hides it from the other, so the header counts tasks not group sizes), **empty status lanes are kept** while every other grouping drops empties (a missing "In progress" column reads as "no such state", not "nothing in progress"), and **dragging is offered only when the columns are statuses** — a drop writes the field the columns represent, and status is the one that is a plain PATCH, so a card that can be dragged into a column which cannot accept it and snaps back is worse than an honestly static column. `toConfig` is deliberately NOT `toQuery`: a query string carries only text so `toQuery` writes `"true"`, and `fromConfig` refuses a string where a toggle belongs, so a view built from query shape would come back with every toggle silently cleared. The project's **order-bearing board is withheld from the chips** — `tree.py` seeds one `board` view per project and it owns every `pm_view_task_positions` row, so offering its ✕ would offer to delete every hand-arranged position; saved views sit at position 300 above the seeded pair and `orderBearingView` is one function used by both the drag handler and the delete guard. **A FIFTH live bug, found the same way as the previous four:** `due_before` was `CAST(:due_before AS timestamptz)` with the raw query-string value — asyncpg infers the parameter's type FROM that cast and then refuses to encode a `str`, so the query never reached Postgres and **`?due_before=…` answered 500** while the hermetic fake, which agrees with whatever SQL it is handed, stayed green. `parse_when` parses on this side and binds a real `datetime`; garbage is a 422 that says what was expected; a naive value is read as UTC rather than inheriting the connection's TimeZone. Two tests — one on the bound value's TYPE, one refusing any `CAST(:param AS timestamp…)` anywhere in the builder — so the next `after=` filter written the obvious way fails in CI instead of in front of a member. **l BUILT 2026-08-07** (mig `155_projects_custom_fields.sql`, `routes/projects/custom_fields.py`, `lib/customFields.ts` + the panel block and the Fields dialog; 47 hermetic + 36 vitest cases, 23 mutants red, 35 checks against a REAL Postgres) — ClickUp's signature feature, and the shape §5's non-goals already recorded as the additive path: **definitions in a table, values denormalised onto the task as JSONB keyed by `field_key`**. NOT a row per (task, field): that is the textbook EAV answer and costs a join per field on every board paint — five fields across two hundred imported tasks is a thousand rows to gather and re-pivot, per render — whereas the JSONB column arrives with the task for free. **The cost is stated rather than discovered**: a value is not referentially tied to its definition, so the DATABASE cannot stop a key no definition owns from being written, and that guarantee moves into Python. Hence the validation IS the feature: an unknown key is a **422 not a silent drop** (a typo that no-ops looks exactly like a save); a patch **MERGES** (a client that knows three of five fields must not wipe the other two — and an older client, or an automation written before a field existed, is precisely that client); an explicit **null CLEARS the key** rather than storing a null, because it is the only way to express "unset this" and a stored null makes "never filled in" and "deliberately emptied" one value in every filter; and **`true` is not the number 1** — `isinstance(True, int)` is True in Python, so the coercers are one-per-type in a dispatch dict specifically so the boolean check can never drift below the number one. **The deliberate departure from Paca:** its research notes record "deleting a definition does not clean task data" as an accepted cost; it is NOT accepted here, because a key left in the JSONB is invisible — no definition means no column, no form row, no filter — until somebody recreates the name and every old value resurfaces carrying the new meaning. The cleared count is reported (R7/R8). Two things a definition may not change once values exist, both because the stored values would stop meaning what they say: **`field_key` is never editable** (it is the identity every value is filed under) and **`field_type` is a 409 naming the count** (text→select cannot re-interpret what is already written); dropping a select option some task holds is refused the same way, adding one is free, and the UI shows the derived key while the name is still being typed since that is the last moment anybody can change it. **Custom fields are REVERTIBLE, which is what makes them first-class:** `patch_task` folds a custom edit into the SAME `field_change` activity under `custom.` rather than inventing an activity type — `record_activity` refuses a type the CHECK does not list, the trap that made every attachment upload answer 422 — and revert restores by **merging onto what the task holds NOW**, never writing back the whole object, since another field may have been edited since and replacing the blob would silently undo that too. **A bug the ticket's own tests caught before it shipped:** `changedValues` compared a form's boolean against a `null` baseline, so a task with an unanswered checkbox sent `open:false` on EVERY save and posted a timeline entry for an edit nobody made — a checkbox has no "unset" state to render, so `false` is its baseline. **And a fence that was quietly a subset check:** `test_projects_routes` asserted a LIST of mounted paths, which catches the module somebody remembered to add a path for; it now also reads the package directory and asserts every module declaring a `@router` route is imported by `__init__.py` — the C1 trap where a missing import mounts nothing while every direct-call test still passes. Verified by deleting the import and watching it fail. **m BUILT 2026-08-07** (mig `156_projects_tags.sql`, `routes/projects/tags.py`, `lib/tags.ts` + the panel picker, the filter row, a `tag` board axis and the Tags dialog; 31 hermetic + 37 vitest cases, 16 mutants red, 30 checks against a REAL Postgres) — the row the research notes left open ON PURPOSE: `paca_pm_research_2026-08.md` row 13 REFUSED Paca's model (*"a bare jsonb string array on tasks. No registry, no colors, no rename/merge — the weakest part of Paca's model"*) and §5 shipped `pm_tasks.tags TEXT[]` in its place with a registry named as additive later. **The array STAYS** — a join table would add a row per tag per task and a join to every board paint, to buy referential integrity this app enforces in one place, whereas the array arrives with the task and its GIN index (146) already answers "tagged X". What the registry buys: **one spelling per tag** (identity is case-INSENSITIVE via a unique index over `lower(name)` so two racing requests cannot create both, display is case-PRESERVING, and the task's array stores the REGISTRY's form — which is what makes "filter by bug finds all of it" true rather than aspirational, and what lets a rename be one statement); **rename**; **merge**; and a **colour**. **Applying an unregistered tag REGISTERS it** — refusing would make tagging a two-step errand (leave the task, create the tag, come back), which is how tagging gets abandoned, and an abandoned tag set is worse than a messy one. **The cost is stated: every typo becomes a tag** — which is exactly why merge is here and is not optional, and why the picker SHOWS the moment of creation rather than minting silently. **A rename onto an existing name is a 409, not a silent merge**: different operations, different outcomes, and one of them destroys a tag — quietly doing the destructive one because the names collided is what stops people using a rename button. **A task carrying BOTH tags ends a merge with the target ONCE** — the case that is easy to get wrong, and getting it wrong leaves a duplicate that renders twice and survives the next merge too; `merged_tags` is pure so that case is asserted directly, and the rewrite runs over affected rows in Python rather than as an `array_replace`, which would leave the duplicate. **TWO tag filters** because both questions get asked and neither answers the other: `tags` is ANY (`&&`) and `tags_all` is ALL (`@>`); collapsing them would silently pick a meaning, and with three tags the answers differ by almost everything. On the board a task with three tags appears in three columns, the same honesty as two assignees appearing in both theirs. **The migration backfills AND rewrites data, deliberately and narrowly:** `tags` has been on `pm_tasks` since 146 and the import path writes them, so an empty registry beside a tagged corpus means the first rename finds nothing; the winning display form is **the spelling people actually use** (most frequent, ties broken deterministically — `min()` alone would canonicalise 400 "Bug" to a single stray "BUG"), and task arrays are then made to agree, only ever swapping one CASING of a tag for another, with the count reported in a NOTICE. **A bug the live run caught in that block:** the canonicalisation used the implicit-comma `FROM pm_tasks t, unnest(t.tags) ... LEFT JOIN` form, where the LEFT JOIN binds only to `unnest(...)` and `t` is not in scope for its ON clause — the migration aborted with "invalid reference to FROM-clause entry for table t". Rewritten as `CROSS JOIN LATERAL … WITH ORDINALITY`, which also fixed a second problem the first version would have shipped: `array_agg(DISTINCT ...)` sorts by its own expression, so every task's tag list would have come back alphabetised. **n BUILT 2026-08-07** (`routes/projects/bulk.py` → `POST /projects/tasks/bulk`, `lib/selection.ts`, `components/BulkBar.tsx` + checkboxes on board and list; 35 hermetic + 32 vitest cases, 16 mutants red, 34 checks against a REAL Postgres; **no migration**) — **the ticket §11.3 names as gating g, so that prerequisite is now satisfied.** It **reuses `automation.apply_task_patch` rather than growing a second writer**: that service exists because WS-27f needed an edit indistinguishable in validation from a human PATCH, and a bulk endpoint with its own field handling would be a third opinion about what a task edit is. Tags go through the same registry the panel uses, because the registry cannot be true if bulk is a second door into the array. **Status is named, never keyed — load-bearing here rather than stylistic**: a selection spans projects and a status id belongs to one root, so `status_id` for fifty tasks across three projects puts two thirds of them in a lane that is not theirs; it is a 422 with its OWN message, because it is the mistake somebody makes by copying a single-task PATCH body and "unknown field" would not explain why the thing that works on one task is refused on fifty. **Assignees and tags are ADD/REMOVE, never SET** — "assign these to Priya" means ALSO Priya, and a replace wipes every individual assignment the fifty tasks already carried; the destructive spelling is absent rather than discouraged. **Shape validated once, outcomes per task**, because those are different failures: an unsettable field is the same mistake for all fifty (422 before any write), while a status name present in one project and absent from another is a fact about THAT task — failing the batch for it makes a mixed selection unusable, which is precisely the selection somebody makes after an import. **An invisible task is SKIPPED, not an error** (R5: per-id reporting says exactly what a per-id 404 says, and aborting would let a caller probe for existence). **One transaction** — a re-triage that half happened is harder to recover from than one that did not, because nobody can tell which half. **Re-asserting a value is not a change** (`moved_people` is pure so the claim is asserted directly): fifty tasks already Priya's would each gain a timeline entry saying nothing, and a count nobody can trust is worse than no count. **ONE notification per person per batch, not one per task** — being handed fifty tasks should ring once and say fifty; fifty bells is a bell people turn off, which is WS-27j's own argument applied to the case that would have broken it. In the browser the selection is **pruned whenever the filter changes** (select forty, narrow to three, press Done must not act on thirty-seven nobody can see), a shift-click ranges over the board's ON-SCREEN order, a two-assignee card drawn in two columns counts once, and the outcome line names every category including the boring ones. **AND IT UNCOVERED A SHIPPED WS-27j BUG** (spec §11.12): `notifications.deliverable` probed only `project_clause('t.root_project_id')` while `core.task_visibility_clause` — whose docstring warns a second implementation "would drift the moment one is edited alone" — carries TWO ways in. So (1) anybody assigned work in a project they hold no grant on was judged undeliverable: they could OPEN the task, the assignment notified nobody, and the response told the assigner they could not see it — the silent assignment WS-27j exists to end, still open for the most common case in a grant-scoped app; and (2) scoping to `root_project_id` ALSO missed a grant made on a SUBPROJECT — the old test asserted that scoping on the argument that "probing `project_id` would miss a grant made on an ancestor", which is BACKWARDS, as a real-Postgres run showed: the closure is recursive and expands DOWNWARD. Fixed to use the shared clause; the test that encoded the wrong reasoning now records why it was wrong. **A fake collision the fix exposed:** `FakeDB` matched the rule-3 probe by substring, and the new clause embeds both `pm_task_assignees` and the closure's `UNION` — exactly the AUDIENCE branch's fingerprint — so `deliverable` got a list of assignees where it expected a visibility answer and four tests failed for a reason unrelated to the code under test. A fake that dispatches on substrings needs fingerprints that are SPECIFIC, not merely present. **o BUILT 2026-08-07** (mig `157_projects_recurrence.sql`, `routes/projects/recurrence.py`, `lib/recurrence.ts` + the repeat row in the task panel; 45 hermetic + 27 vitest cases, 31 mutants red, 39 checks against a REAL Postgres) — **NO SCHEDULER, and that is FORCED rather than chosen**: §5's non-goals say `/workflows` is the only engine (ADR-028/D6), so a recurrence worker here would be exactly the second engine the spec forbids. The successor is created **when a task CLOSES** — `apply_status_transition` already owns that moment, so a task finished from the board, from My work, from an automation or from a bulk edit all recur identically, and a second call site would be a fifth way to finish a task that forgets to. **The cost is stated:** a series only advances when somebody finishes the current one — a monthly report nobody closes does not pile up twelve copies (right), but a daily standup nobody ticks does not appear tomorrow (the honest limitation); materialising ahead is already reachable through the engine that owns scheduling (cron trigger + the `pm_task` node WS-27f added), so nothing needs undoing. **The anchor is PER RULE because the two answers mean different things**: `due` keeps the schedule ("stock count on the 1st" stays on the 1st however late the last was closed, so the series does not drift) and `completed` measures from when the work was actually done ("water the plants every 3 days" restarts when you water them). A `due` anchor also **catches up** — a monthly task closed six weeks late would otherwise produce a successor already overdue the moment it appeared — and the missed occurrences are SKIPPED rather than backfilled, because nobody wants four copies of a standup they did not attend. **The date arithmetic is where this is either right or quietly wrong for a year**, so it is pure and each case is one assertion: January 31st monthly (clamped at COMPUTATION time and stored as asked — storing the clamp permanently demotes the rule to the 28th after its first February), February 29th yearly, "every other Mon and Thu" (within a week it takes the next allowed day and only jumps `interval` weeks when the week runs out; a naive `+14 days` alternates between the two days instead of giving both days of every second week), and a 09:00 standup staying at 09:00. **Closing twice must not spawn twice** — a task can cross into `done` repeatedly (close, reopen to add a note, close again) and every crossing hits the same seam, so `recurrence_spawned_at` guards it and is NEVER cleared: reopening undoes `completed_at` but does not un-emit a successor that may already have been worked on. **Stopping a series keeps the work** (detach, not delete): they are real tasks, some finished, and a button that swept away three months of completed reports is one nobody presses twice. **TWO BUGS THE LIVE RUN CAUGHT AND READING COULD NOT:** (1) the weekly CHECK passed the very row it existed to reject — `array_length('{}', 1)` returns **NULL**, `NULL >= 1` is NULL, and a CHECK only FAILS on false, so a weekly rule with no weekdays inserted happily past a constraint that looked correct; `coalesce(…, 0)` fixes it and a test asserts the coalesce is present, since the hermetic suite has no database to try the expression on; and (2) `_next_number`/`_default_status` were reimplementations, one of which invented a column (`last_number`; the real one is `last_value`) — replaced by `core`'s own `next_task_number` and `load_default_status`, which is the same mistake WS-27n had just been careful to avoid, made two tickets later in the same package. **A third, caught by its own test:** `int(rule.get("interval") or 1)` turns an explicit `0` into "every 1" — a typo that looks exactly like a save, and one the DB CHECK would then have refused as a 500 rather than a 422. In the browser **the SENTENCE is the feature** — a form of five controls is a shape, whereas "Every 2 weeks on Mon, Thu, keeping to the schedule" is something somebody can check before committing, shown LIVE rather than on save because picking the wrong anchor is invisible until a cadence has drifted for three months; the occurrence limit reads as what is LEFT not the cap, and switching frequency clears the fields the new one does not use so a stale `day_of_month` cannot reappear. **p BUILT 2026-08-07** (`routes/projects/relations.py` → `GET /tasks/{id}/relations`, `lib/relations.ts` + the relations block in the panel; 21 hermetic + 16 vitest cases, 11 mutants red, 19 checks against a REAL Postgres; **no migration**) — closes *"data with no surface is a promise the product does not keep"*. **BOTH halves were genuinely unreachable, for different reasons:** links could be CREATED and DELETED since WS-27a but never LISTED (`get_task` returns a *count*), and subtasks could be created from the panel but never listed either (`?parent_task_id=` existed and nothing called it). What was missing was a way to read them and **one rule nobody had written down: `blocks` may not form a cycle.** `assert_no_task_cycle` has guarded `parent_task_id` since WS-27a and the identical hazard sat unguarded on links — A blocks B blocks C blocks A is a deadlock no human can resolve by finishing something, and every walk over it runs forever. The new guard is bounded by the same `MAX_DEPTH` and **tracks what it has seen**, because data can ALREADY contain a loop (every link created before the guard went in unchecked) and the walk must terminate over one rather than spin. **Only `blocks` is guarded** — a cycle in `relates_to` is redundant, not harmful, and refusing one would be a rule with no failure to prevent. **Blocked-ness is DERIVED and SHOWN, never ENFORCED**: refusing to close a blocked task is the obvious next step and is deliberately not taken, because dependencies in a real workspace are approximate and a tool that will not let somebody finish work they have finished is one they route around — after which the links stop being maintained and the feature is worse than absent. **Visibility is applied to the CHILDREN, not inherited from the parent**: a subtask can be moved into a project the reader cannot see, and listing it because its parent is readable would disclose a title from behind a grant (the live run asserts both the absence and that the title does not appear). ONE endpoint carries BOTH directions, because `blocks` outgoing means "this holds those up" and incoming means "this is waiting" — a client given one side would ask twice and still not know which was which; **Blocked by is shown FIRST** since it is the only section that changes what to do next, and empty sections are dropped because six empty headings on every task is how a panel becomes something people scroll past. Progress counts the status CATEGORY not `completed_at` (a project can name its finished lane anything, and `cancelled` is resolved), and reads as "1 of 3" rather than 33% | | **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | ✅ **a + b BUILT 2026-08-06 · b-write BUILT 2026-08-07** · 🟢 c–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE).. **a BUILT 2026-08-06** (mig `148_people_key_shape.sql` + `scripts/import_hr_people.py`; 22 cases, 11 mutants red, 1 equivalent): `UNIQUE(name)` dropped, partial unique on `lower(email)`, status CHECK. **P-1 did not name its own consequence** — the HR importer upserts `ON CONFLICT (name)` and would have failed outright, so a `source_key` (`:`) carries the upsert instead, backfilled BEFORE the constraint is dropped while `name` is still distinct. **Neither new constraint may block a deploy** (main was bitten twice this month): a duplicate address is quarantined into a new `email_conflict` column with a deterministic winner rather than failing `CREATE UNIQUE INDEX`, and the status CHECK is added `NOT VALID` then validated in a guarded block, so an unanticipated legacy value leaves a NOTICE instead of stopping the deploy. ⚠️ `schema.generated.sql` NOT refreshed — needs a live DB; regenerate on the first deploy that applies 148. **b BUILT 2026-08-06** (mig `149_people.sql`, `routes/people/`, `src/app/people/`; 32 hermetic + 28 vitest cases, 11 mutants red): `/people` directory, person page with all four panels, and the People Center's "Directory & org chart" sub-app flipped live — closing WS-13's outstanding read view. **Its own feature slug**, not `feature:tasks`: a manager who needs the org chart should not be handed the personal GTD task manager to get it. The gate is new but the HR **projection is imported** from `tasks.core` and a test asserts the function's *identity*, since two answers to "may this caller see skills" are two answers waiting to drift. Three filters (the `q` skills clause, `skill`, `has_capacity`) are dropped without `admin:members:read` so search cannot become an oracle for the hidden field — and the response carries `hr_visible` so the UI says "restricted" rather than leaving a blank strip to read as "nobody filled it in". Load is **computed from open assigned tasks** and carries `unestimated`, because a bar built from the estimate sum alone shows somebody holding thirty un-estimated tasks as completely free. The work panel is scoped by the **viewer's** grants and answers `available:false` without `feature:projects`. **Registration is FIVE places, not four** — the fifth is `test_org_access_enforcement.GATED_ROUTERS`, hand-maintained, where an absent router is unchecked rather than passing; also added the named `test_projects_is_registered_on_both_sides` that WS-27a never wrote. **b-write BUILT 2026-08-07** (`people/components/PersonEditor.tsx`, `people/lib/form.ts`, `people/lib/write.ts`; 26 hermetic + 23 vitest cases, 7 mutants red): closes the regression the 2026-08-06 scope narrowing opened — deleting the tasks app's People view took `PersonEditor` with it, and with it the only UI for creating a person, editing skills and uploading a résumé. The GET-only `/api/people` proxy is **unchanged**: the writes go to `/api/tasks/people`, where they have always lived. Controls are absent rather than disabled, driven by a new **`can_manage`** flag on the reads — hide-rather-than-disable is impossible unless the read tells the UI, and the alternative is drawing the button and letting the click find a 403. **Restoring it turned up three ways migration 148 had already broken the write routes**, each a 500 in front of an admin rather than a test failure: the status vocabulary moved (49's `inactive`/`on_leave` vs 148's CHECK) and is now ONE tuple shared by filter, facets, select and validation; `create_person` still refused a duplicate NAME, preserving precisely the behaviour 148 dropped `UNIQUE(name)` to remove; and nothing checked the address 148 made unique, so a duplicate was an `IntegrityError` instead of a 409 naming the other row. **The lesson recorded in spec §10:** a migration that changes a table's shape has to be walked against every route that WRITES it — the read routes were built after 148 and were correct by construction, the write routes predated it and were never revisited | @@ -471,6 +471,10 @@ banner (D6) · "Agent Creator"→"Agent Workshop" sweep (R3, 5 sites) · `llm_caching_memory.md` proxy-hook sections struck per its own header · drawio §12's stray Hostinger-token action item moved to WS-2's list. +> 📋 **Handing this to another agent?** Start at [`HANDOVER.md`](HANDOVER.md) — branch state, +> the two migrations that are on no real database yet, the verification protocol, the ticket +> queue in dependency order, and a list of every trap that cost real time. + ## 6. Owner-gate registry (agents must refuse these) > **Two identity-boundary items, measured on the running deployment 2026-08-05 — @@ -655,8 +659,12 @@ should the probe report no-scope, is likewise the owner's act · **the five WS-27 Projects gates** (`specs/project_management_app.md`), (a)–(d) registered 2026-08-05, (e) added 2026-08-08: **(a) running either ClickUp import endpoint against the production workspace** — -⚠️ **ALSO BLOCKED ON WS-29a AS OF 2026-08-08, and this is now the binding -constraint rather than the mapping decision.** CommandCenter is becoming +~~⚠️ **ALSO BLOCKED ON WS-29a AS OF 2026-08-08**~~ — **LIFTED the same day: +migration 158 keyed all seventeen `pm_*` tables, which was the reason to +wait.** ⚠️ Two conditions replace it: migration 158 **must be applied to the +target database first** (it is on no real box yet — the deploy path is +broken, WS-25), and the mapping decision below still stands. Kept struck +because the reasoning is the reusable part: CommandCenter is becoming multi-tenant and all seventeen `pm_*` tables carry no `organization_id` (`specs/multi_tenancy.md` §2). Importing a real workspace now writes hundreds of tasks, activities, attachments and grants into unscoped tables, which turns From 284fe7dd766ef016600e06283fbb38fe65d1f4ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 06:23:32 +0000 Subject: [PATCH 18/22] test(live): move the live verification harnesses into the repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ A gap in my own handover, found by checking it rather than asserting it. HANDOVER.md §2.1 told the next agent that the live-Postgres run is non-negotiable and to copy the patterns from the scratchpad — and all twelve scripts lived in /tmp, in a container that gets reclaimed. They would have been gone before anybody read the sentence pointing at them. These found a bug in every single ticket they were written for, several times with the entire hermetic suite green. That is the whole argument for keeping them, and it is why they belong in the repository. They are not unit tests: they need a real Postgres 16 with the full migration set, they drive the REAL endpoint functions, and they exit non-zero on any failed assertion. Named live_*.py so pytest does not collect them — verified both ways (tests/live/ collects nothing; the unit suite is unaffected). The README tabulates what each one pins, which doubles as a list of the things a fake structurally cannot catch: no type system, so AmbiguousParameterError is invisible; no planner, so an ambiguous ORDER BY is invisible; no constraints, so a CHECK that never fires looks like one that works; no lower(), so a byte-exact UNIQUE agrees with code that assumed case-folding. Every one of those was a real defect on this branch. ⚠️ Documented prominently: most of them TRUNCATE pm_projects CASCADE in seed(). Safe against a scratch database, catastrophic against anything you care about — and the next agent has database access, which is precisely why that warning has to be louder than it needed to be here. Also includes prove_bootstrap.sh, which demonstrates rather than asserts that `git reset --hard` renames, so a self-rewriting deploy script keeps its old inode, runs stale steps against a new tree, and exits 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/HANDOVER.md | 7 +- tests/live/README.md | 54 +++++ tests/live/live_ws27k.py | 217 +++++++++++++++++++ tests/live/live_ws27l.py | 236 +++++++++++++++++++++ tests/live/live_ws27m.py | 209 ++++++++++++++++++ tests/live/live_ws27n.py | 256 ++++++++++++++++++++++ tests/live/live_ws27o.py | 230 ++++++++++++++++++++ tests/live/live_ws27p.py | 190 +++++++++++++++++ tests/live/live_ws27q.py | 188 +++++++++++++++++ tests/live/live_ws27r.py | 155 ++++++++++++++ tests/live/live_ws27s.py | 146 +++++++++++++ tests/live/live_ws27t.py | 132 ++++++++++++ tests/live/live_ws29.py | 387 ++++++++++++++++++++++++++++++++++ tests/live/live_ws29e.py | 344 ++++++++++++++++++++++++++++++ tests/live/prove_bootstrap.sh | 135 ++++++++++++ 15 files changed, 2884 insertions(+), 2 deletions(-) create mode 100644 tests/live/README.md create mode 100644 tests/live/live_ws27k.py create mode 100644 tests/live/live_ws27l.py create mode 100644 tests/live/live_ws27m.py create mode 100644 tests/live/live_ws27n.py create mode 100644 tests/live/live_ws27o.py create mode 100644 tests/live/live_ws27p.py create mode 100644 tests/live/live_ws27q.py create mode 100644 tests/live/live_ws27r.py create mode 100644 tests/live/live_ws27s.py create mode 100644 tests/live/live_ws27t.py create mode 100644 tests/live/live_ws29.py create mode 100644 tests/live/live_ws29e.py create mode 100644 tests/live/prove_bootstrap.sh diff --git a/ai-company-brain/HANDOVER.md b/ai-company-brain/HANDOVER.md index 59220202..90e17fb1 100644 --- a/ai-company-brain/HANDOVER.md +++ b/ai-company-brain/HANDOVER.md @@ -86,8 +86,11 @@ codebase, most of them during this branch's work. su postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D -o '-k /var/tmp -p 55432' start" ``` DSN: `postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432`. - Drive the **real endpoint functions**, not a mock. Patterns to copy live in the scratchpad as - `live_ws27*.py` / `live_ws29.py`. + Drive the **real endpoint functions**, not a mock. **Twelve working harnesses are in + [`tests/live/`](../tests/live/)** with a README explaining what each one pins — read that + table before writing a new one, because it is a list of the things a fake structurally + cannot catch. ⚠️ Most of them `TRUNCATE pm_projects CASCADE`; point them at a throwaway + database, never production. **This found a bug in every single ticket on this branch — including several where the entire hermetic suite was green.** It is not optional and it is not a formality. diff --git a/tests/live/README.md b/tests/live/README.md new file mode 100644 index 00000000..33a1ba1d --- /dev/null +++ b/tests/live/README.md @@ -0,0 +1,54 @@ +# Live verification harnesses + +**These found a bug in every single ticket they were written for — several times with the whole +hermetic suite green.** That is the entire argument for their existence, and it is why they are +in the repository rather than in somebody's scratch directory. + +They are **not** unit tests. They need a real Postgres 16 with the full migration set applied, +they drive the **real endpoint functions** (not mocks, not a `TestClient`), and each one prints +`ok`/`FAIL` per assertion and exits non-zero on any failure. + +Named `live_*.py`, so pytest does not collect them — verified. Do not rename them to `test_*`. + +## Running one + +```bash +su postgres -c "/usr/lib/postgresql/16/bin/pg_ctl -D -o '-k /var/tmp -p 55432' start" +uv run python tests/live/live_ws29.py +``` + +Each script sets its own `DATABASE_URL` at the top — +`postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432`. **Change it to point at your +database**, and read the next paragraph before you do. + +⚠️ **Most of these `TRUNCATE pm_projects CASCADE` in their `seed()`.** That is safe against a +scratch database and catastrophic against anything you care about. Point them at a throwaway +copy, never at production, and never at a database whose contents you have not just backed up. + +## What each one pins + +| Script | Ticket | The thing only a database could answer | +|---|---|---| +| `live_ws27k.py` | filters | `CAST(:x AS timestamptz)` with a bound `str` — asyncpg refuses it | +| `live_ws27l.py` | custom fields | JSONB round-trip; asyncpg has no codec for a bare dict | +| `live_ws27m.py` | tags | `CROSS JOIN LATERAL … WITH ORDINALITY`; `array_agg(DISTINCT …)` reordering | +| `live_ws27n.py` | bulk edit | The visibility clause's two doors, which the fake conflated | +| `live_ws27o.py` | recurrence | `array_length('{}',1)` is NULL, and a CHECK only fails on FALSE | +| `live_ws27p.py` | relations | Two-direction `UNION`; child visibility not inherited from the parent | +| `live_ws27q.py` | calendar | Interval overlap; `AT TIME ZONE 'UTC'` vs the session's `TimeZone` | +| `live_ws27r.py` | search | `AmbiguousParameterError`; backslash as LIKE's escape on a bound param | +| `live_ws27s.py` | task card | Page-wide aggregates over `= ANY(CAST(:ids AS uuid[]))` | +| `live_ws27t.py` | timeline | Edges with both ends in a window; a DATE beside a timestamptz in `UNION ALL` | +| `live_ws29.py` | tenancy | **Two tenants, real routes — proves isolation and 404-never-403** | +| `live_ws29e.py` | admin tenancy | Two orgs, two admins — roster, invite, roles, groups, overrides | +| `prove_bootstrap.sh` | WS-25 D1 | `git reset --hard` renames, so a self-rewriting script runs stale steps and **exits 0** | + +## Why they are worth keeping + +A hermetic fake is a mirror, and a mirror can only agree with itself. It has no type system, so +`AmbiguousParameterError` is invisible to it. It has no planner, so an ambiguous `ORDER BY` is +invisible. It has no constraints, so a `CHECK` that never fires looks like a `CHECK` that works. +It has no `lower()`, so a byte-exact `UNIQUE` index that should have been case-folded agrees +with the code that assumed otherwise. + +Every one of those was a real defect on this branch, and every one of them was caught here. diff --git a/tests/live/live_ws27k.py b/tests/live/live_ws27k.py new file mode 100644 index 00000000..9152a113 --- /dev/null +++ b/tests/live/live_ws27k.py @@ -0,0 +1,217 @@ +"""WS-27k against a REAL Postgres. + +The hermetic fake agrees with whatever SQL it is handed. This does not: it runs +the actual endpoint functions against a database with the actual migrations +applied, which is the only thing that catches a missing column, a CHECK the +code violates, or a cast Postgres refuses. +""" +import asyncio +import os +import sys +import uuid +from datetime import UTC, datetime + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects.core import Page # noqa: E402 +from gateway.routes.projects import tasks as tasks_mod # noqa: E402 +from gateway.routes.projects import views as views_mod # noqa: E402 +from gateway.routes.projects.views import ViewIn # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +OTHER = "ravi@fracktal.in" + +failures: list[str] = [] + + +def check(label: str, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + pid = str(uuid.uuid4()) + await db.execute( + text( + "INSERT INTO pm_projects (id, name, source, created_by) " + "VALUES (CAST(:id AS uuid), 'Ops', 'manual', :me)" + ), + {"id": pid, "me": ME}, + ) + await db.execute( + text( + "INSERT INTO pm_project_grants (project_id, subject, created_by) " + "VALUES (CAST(:p AS uuid), :s, :s)" + ), + {"p": pid, "s": ME}, + ) + lanes = {} + for i, (name, category) in enumerate( + [("To do", "todo"), ("Doing", "in_progress"), ("Done", "done")] + ): + sid = str(uuid.uuid4()) + lanes[category] = sid + await db.execute( + text( + "INSERT INTO pm_task_statuses " + "(id, project_id, name, position, category, is_default) " + "VALUES (CAST(:id AS uuid), CAST(:p AS uuid), :n, :pos, :c, :d)" + ), + {"id": sid, "p": pid, "n": name, "pos": i, "c": category, + "d": category == "todo"}, + ) + + counter = {"n": 0} + + async def task(title, category, *, due=None, people=(), importance=None, + description=None): + tid = str(uuid.uuid4()) + counter["n"] += 1 + await db.execute( + text( + "INSERT INTO pm_tasks " + "(id, project_id, root_project_id, status_id, title, " + " description, importance, due_at, source, created_by, task_number) " + "VALUES (CAST(:id AS uuid), CAST(:p AS uuid), CAST(:p AS uuid), " + " CAST(:s AS uuid), :t, :d, :i, " + " CAST(:due AS timestamptz), 'manual', :me, :num)" + ), + {"id": tid, "p": pid, "s": lanes[category], "t": title, + "d": description, "i": importance, "due": due, "me": ME, "num": counter["n"]}, + ) + for who in people: + await db.execute( + text( + "INSERT INTO pm_task_assignees (task_id, assignee, assigned_by) " + "VALUES (CAST(:t AS uuid), :a, :me)" + ), + {"t": tid, "a": who, "me": ME}, + ) + return tid + + await task("Fix the extruder", "todo", due=datetime(2020, 1, 1, tzinfo=UTC), + people=[ME, OTHER], importance=3) + await task("Shipped late", "done", due=datetime(2020, 1, 1, tzinfo=UTC), people=[ME]) + await task("Nobody's problem", "todo", description="jammed nozzle") + await task("Ravi's job", "in_progress", people=[OTHER]) + await db.commit() + return pid + finally: + await db.close() + + +async def main(): + pid = await seed() + user = UserContext(email=ME, role="member") + + async def ls(**kw): + return await tasks_mod.list_tasks( + user=user, project_id=pid, page=Page(page=1, page_size=100), **kw + ) + + titles = lambda r: sorted(t["title"] for t in r.rows) # noqa: E731 + + all_rows = await ls() + check("all four tasks are visible", all_rows.total, 4) + check( + "assignees arrive on the LIST, sorted", + next(t["assignees"] for t in all_rows.rows if t["title"] == "Fix the extruder"), + [ME, OTHER], + ) + check( + "an unassigned task still carries the key", + next(t["assignees"] for t in all_rows.rows if t["title"] == "Nobody's problem"), + [], + ) + + check("status_category=todo", titles(await ls(status_category="todo")), + ["Fix the extruder", "Nobody's problem"]) + check("two categories", (await ls(status_category="todo,done")).total, 3) + check("overdue excludes the finished one", + titles(await ls(overdue=True)), ["Fix the extruder"]) + check("assignee, capitalised", titles(await ls(assignee="Priya@Fracktal.IN")), + ["Fix the extruder", "Shipped late"]) + check("assignees CSV", (await ls(assignees=f"{ME},{OTHER}")).total, 3) + check("unassigned", titles(await ls(unassigned=True)), ["Nobody's problem"]) + check("q searches the description too", titles(await ls(q="nozzle")), + ["Nobody's problem"]) + check("q is case-insensitive", (await ls(q="EXTRUDER")).total, 1) + check("importance_gte", titles(await ls(importance_gte=3)), ["Fix the extruder"]) + check("due_before, as a bare date from a query string", + (await ls(due_before="2021-01-01")).total, 2) + check("due_before, as a full timestamp", + (await ls(due_before="2021-01-01T00:00:00Z")).total, 2) + try: + await ls(due_before="tomorrow") + check("an unparseable due_before is refused", "no error", "422") + except Exception as exc: + check("an unparseable due_before is refused", + getattr(exc, "status_code", None), 422) + check("filters combine", titles(await ls(status_category="todo", assignee=ME)), + ["Fix the extruder"]) + check("a filter that matches nothing is empty, not an error", + (await ls(q="zzzz")).total, 0) + + try: + await ls(status_category="in-progress") + check("unknown category is refused", "no error", "422") + except Exception as exc: # HTTPException + check("unknown category is refused", getattr(exc, "status_code", None), 422) + + # Saved views, round trip through the real column. + payload = ViewIn( + name="My open work", + view_type="board", + config={ + "filters": {"status_category": "todo", "assignee": ME, "colour": "red"}, + "group_by": "assignee", + "nonsense": 1, + }, + position=300.0, + ) + + created = await views_mod.create_view(pid, payload, user=user) + check("unknown config keys are dropped on the way in", + created["config"], + {"filters": {"status_category": "todo", "assignee": ME}, + "group_by": "assignee"}) + + listed = await views_mod.list_views(pid, user=user) + stored = next(v for v in listed["rows"] if v["id"] == created["id"]) + check("the config survives a round trip through jsonb", + stored["config"]["group_by"], "assignee") + + saved = await ls(**stored["config"]["filters"]) + check("the saved view and the same filters typed by hand agree", + titles(saved), titles(await ls(status_category="todo", assignee=ME))) + + patched = await views_mod.patch_view( + created["id"], + ViewIn(config={"filters": {"overdue": True}, "group_by": "phase"}), + user=user, + ) + check("a patch normalises too, and an unknown grouping falls back", + patched["config"], {"filters": {"overdue": True}, "group_by": "status"}) + + gone = await views_mod.delete_view(created["id"], user=user) + check("delete reports its cascade", gone["cascaded"], {"positions": 0}) + + print() + if failures: + print(f"{len(failures)} FAILED: {failures}") + raise SystemExit(1) + print("all live checks passed") + + +asyncio.run(main()) diff --git a/tests/live/live_ws27l.py b/tests/live/live_ws27l.py new file mode 100644 index 00000000..aa0bca59 --- /dev/null +++ b/tests/live/live_ws27l.py @@ -0,0 +1,236 @@ +"""WS-27l against a REAL Postgres. + +Custom fields are almost entirely JSONB semantics — the `-` operator, the `?` +operator, the GIN index, and whether asyncpg will encode a dict at all. A fake +re-implements those in Python and can only agree with itself. +""" +import asyncio +import os +import sys +import uuid +from datetime import UTC, datetime + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import activities as acts_mod # noqa: E402 +from gateway.routes.projects import custom_fields as cf_mod # noqa: E402 +from gateway.routes.projects import tasks as tasks_mod # noqa: E402 +from gateway.routes.projects.core import Page, TaskIn # noqa: E402 +from gateway.routes.projects.custom_fields import FieldIn # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + pid, sid = str(uuid.uuid4()), str(uuid.uuid4()) + await db.execute(text( + "INSERT INTO pm_projects (id, name, source, created_by) " + "VALUES (CAST(:id AS uuid), 'Ops', 'manual', :me)"), {"id": pid, "me": ME}) + await db.execute(text( + "INSERT INTO pm_project_grants (project_id, subject, created_by) " + "VALUES (CAST(:p AS uuid), :s, :s)"), {"p": pid, "s": ME}) + await db.execute(text( + "INSERT INTO pm_task_statuses (id, project_id, name, position, category, " + "is_default) VALUES (CAST(:id AS uuid), CAST(:p AS uuid), 'To do', 1, " + "'todo', true)"), {"id": sid, "p": pid}) + ids = [] + for n, title in enumerate(["Fix the extruder", "Ship the firmware"], start=1): + tid = str(uuid.uuid4()) + ids.append(tid) + await db.execute(text( + "INSERT INTO pm_tasks (id, project_id, root_project_id, status_id, " + "title, source, created_by, task_number) VALUES (CAST(:id AS uuid), " + "CAST(:p AS uuid), CAST(:p AS uuid), CAST(:s AS uuid), :t, 'manual', " + ":me, :n)"), {"id": tid, "p": pid, "s": sid, "t": title, "me": ME, "n": n}) + await db.commit() + return pid, ids + finally: + await db.close() + + +async def raw(sql, **params): + db = await get_db() + try: + return (await db.execute(text(sql), params)).fetchall() + finally: + await db.close() + + +async def main(): + pid, (t1, t2) = await seed() + user = UserContext(email=ME, role="member") + + # ── Definitions ──────────────────────────────────────────────────────── + customer = await cf_mod.create_field( + pid, FieldIn(name="Customer PO #", field_type="text"), user=user) + check("a key is derived from the name", customer["field_key"], "customer_po") + + region = await cf_mod.create_field( + pid, FieldIn(name="Region", field_type="select", options=["EU", "IN", "EU"]), + user=user) + check("options are deduped on the way in", region["options"], ["EU", "IN"]) + + budget = await cf_mod.create_field( + pid, FieldIn(name="Budget", field_type="number"), user=user) + tags = await cf_mod.create_field( + pid, FieldIn(name="Teams", field_type="multi_select", + options=["ops", "eng"]), user=user) + + listed = await cf_mod.list_fields(pid, user=user) + check("all four definitions come back", listed["total"], 4) + + try: + await cf_mod.create_field( + pid, FieldIn(name="Region", field_type="text"), user=user) + check("a duplicate key is refused", "no error", "409") + except Exception as exc: + check("a duplicate key is refused", getattr(exc, "status_code", None), 409) + + # ── Values ───────────────────────────────────────────────────────────── + after = await tasks_mod.patch_task(t1, TaskIn(custom_fields={ + "customer_po": " PO-1234 ", "region": "EU", "budget": 2500, + "teams": ["ops", "ops", "eng"], + }), user=user) + check("values round-trip through jsonb as real types", after["custom_fields"], + {"customer_po": "PO-1234", "region": "EU", "budget": 2500, + "teams": ["ops", "eng"]}) + + after = await tasks_mod.patch_task(t1, TaskIn(custom_fields={"budget": 3000}), + user=user) + check("a patch MERGES rather than replacing", + sorted(after["custom_fields"]), ["budget", "customer_po", "region", "teams"]) + check("and the merged key is the new value", after["custom_fields"]["budget"], 3000) + + after = await tasks_mod.patch_task(t1, TaskIn(custom_fields={"region": None}), + user=user) + check("an explicit null REMOVES the key", "region" in after["custom_fields"], False) + + untouched = await tasks_mod.get_task(t2, user=user) + check("a task nobody set values on is {} not null", + untouched["custom_fields"], {}) + + for label, patch in ( + ("an unknown key", {"custmer": "x"}), + ("a string in a number field", {"budget": "3000"}), + ("a boolean in a number field", {"budget": True}), + ("an option that is not offered", {"region": "US"}), + ("a bare string in a multi-select", {"teams": "ops"}), + ): + try: + await tasks_mod.patch_task(t2, TaskIn(custom_fields=patch), user=user) + check(f"{label} is refused", "no error", "422") + except Exception as exc: + check(f"{label} is refused", getattr(exc, "status_code", None), 422) + + fresh = await tasks_mod.get_task(t2, user=user) + check("a refused patch wrote nothing at all", fresh["custom_fields"], {}) + + # ── The list endpoint carries them ───────────────────────────────────── + listed_tasks = await tasks_mod.list_tasks( + user=user, project_id=pid, page=Page(page=1, page_size=100)) + by_id = {r["id"]: r for r in listed_tasks.rows} + check("the LIST carries custom values, not only the single read", + by_id[t1]["custom_fields"]["customer_po"], "PO-1234") + check("and an empty one is still an object on the list", + by_id[t2]["custom_fields"], {}) + + # ── Timeline and revert ──────────────────────────────────────────────── + timeline = await acts_mod.get_timeline(t1, user=user, page=Page(page=1, page_size=50)) + field_changes = [a for a in timeline["rows"] if a["type"] == "field_change"] + check("a custom edit lands on the timeline as a field_change", + len(field_changes) >= 3, True) + latest = field_changes[0] + check("no new activity type was invented", latest["type"], "field_change") + check("the change names the custom key", + latest["meta"]["changes"][0]["field"], "custom.region") + + reverted = await acts_mod.revert_change(latest["id"], user=user) + check("a custom field is revertible", reverted["reverted"], ["custom.region"]) + back = await tasks_mod.get_task(t1, user=user) + check("and the value came back", back["custom_fields"].get("region"), "EU") + check("without disturbing its neighbours", + back["custom_fields"]["budget"], 3000) + + # ── Definition edits ─────────────────────────────────────────────────── + try: + await cf_mod.patch_field(region["id"], FieldIn(field_type="text"), user=user) + check("a type change is refused while values exist", "no error", "409") + except Exception as exc: + check("a type change is refused while values exist", + getattr(exc, "status_code", None), 409) + + try: + await cf_mod.patch_field(region["id"], FieldIn(options=["IN"]), user=user) + check("dropping an option in use is refused", "no error", "409") + except Exception as exc: + check("dropping an option in use is refused", + getattr(exc, "status_code", None), 409) + + widened = await cf_mod.patch_field( + region["id"], FieldIn(options=["EU", "IN", "US"]), user=user) + check("but ADDING an option is fine", widened["options"], ["EU", "IN", "US"]) + + renamed = await cf_mod.patch_field( + customer["id"], FieldIn(name="Customer PO"), user=user) + check("the label is editable", renamed["name"], "Customer PO") + check("and the key did not move with it", renamed["field_key"], "customer_po") + + try: + await cf_mod.patch_field(customer["id"], FieldIn(field_key="po"), user=user) + check("the key itself is refused", "no error", "422") + except Exception as exc: + check("the key itself is refused", getattr(exc, "status_code", None), 422) + + # ── Delete strips values ─────────────────────────────────────────────── + gone = await cf_mod.delete_field(budget["id"], user=user) + check("delete reports how many values it cleared", + gone["cascaded"], {"values_cleared": 1}) + stripped = await tasks_mod.get_task(t1, user=user) + check("the value is actually gone from the task", + "budget" in stripped["custom_fields"], False) + check("and the other values survived", + sorted(stripped["custom_fields"]), ["customer_po", "region", "teams"]) + + # ── The index is usable ──────────────────────────────────────────────── + hit = await raw( + "SELECT count(*) AS n FROM pm_tasks " + "WHERE root_project_id = CAST(:p AS uuid) " + " AND custom_fields @> CAST(:probe AS jsonb)", + p=pid, probe='{"region": "EU"}') + check("a containment query finds the task", hit[0].n, 1) + + # The planner will seq-scan a two-row table whatever indexes exist, so the + # honest claim is that the index is THERE and is the right kind — not that + # this particular query chose it. + idx = await raw( + "SELECT indexdef FROM pg_indexes " + "WHERE tablename = 'pm_tasks' AND indexname = 'idx_pm_tasks_custom_fields'") + check("the containment index exists", len(idx), 1) + check("and it is a GIN index over custom_fields", + "USING gin (custom_fields jsonb_path_ops)" in idx[0].indexdef, True) + + print() + if failures: + print(f"{len(failures)} FAILED: {failures}") + raise SystemExit(1) + print("all live checks passed") + + +asyncio.run(main()) diff --git a/tests/live/live_ws27m.py b/tests/live/live_ws27m.py new file mode 100644 index 00000000..70164d74 --- /dev/null +++ b/tests/live/live_ws27m.py @@ -0,0 +1,209 @@ +"""WS-27m against a REAL Postgres. + +The registry's claims are array operations — `&&`, `@>`, `array_remove`, the +case-insensitive unique index, and a merge that rewrites rows. A fake +re-implements those in Python and can only agree with itself. +""" +import asyncio +import os +import sys +import uuid + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import tags as tags_mod # noqa: E402 +from gateway.routes.projects import tasks as tasks_mod # noqa: E402 +from gateway.routes.projects.core import Page, TaskIn # noqa: E402 +from gateway.routes.projects.tags import MergeIn, TagIn # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +PID = "11111111-1111-1111-1111-111111111111" +SID = "22222222-2222-2222-2222-222222222222" +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + await db.execute(text( + "INSERT INTO pm_projects (id, name, source, created_by) " + "VALUES (CAST(:p AS uuid), 'Ops', 'manual', :me)"), {"p": PID, "me": ME}) + await db.execute(text( + "INSERT INTO pm_project_grants (project_id, subject, created_by) " + "VALUES (CAST(:p AS uuid), :s, :s)"), {"p": PID, "s": ME}) + await db.execute(text( + "INSERT INTO pm_task_statuses (id, project_id, name, position, category, " + "is_default) VALUES (CAST(:s AS uuid), CAST(:p AS uuid), 'To do', 1, " + "'todo', true)"), {"s": SID, "p": PID}) + ids = [] + for n in range(1, 4): + tid = str(uuid.uuid4()) + ids.append(tid) + await db.execute(text( + "INSERT INTO pm_tasks (id, project_id, root_project_id, status_id, " + "title, source, created_by, task_number) VALUES (CAST(:id AS uuid), " + "CAST(:p AS uuid), CAST(:p AS uuid), CAST(:s AS uuid), :t, 'manual', " + ":me, :n)"), {"id": tid, "p": PID, "s": SID, "t": f"task {n}", + "me": ME, "n": n}) + await db.commit() + return ids + finally: + await db.close() + + +async def main(): + t1, t2, t3 = await seed() + user = UserContext(email=ME, role="member") + + async def ls(**kw): + return await tasks_mod.list_tasks( + user=user, project_id=PID, page=Page(page=1, page_size=100), **kw) + + titles = lambda r: sorted(x["title"] for x in r.rows) # noqa: E731 + + # ── Auto-registration on use ─────────────────────────────────────────── + after = await tasks_mod.patch_task(t1, TaskIn(tags=["Bug", "ops"]), user=user) + check("tags round-trip as a text[]", after["tags"], ["Bug", "ops"]) + + registry = await tags_mod.list_tags(PID, user=user) + check("using a tag registers it", sorted(r["name"] for r in registry["rows"]), + ["Bug", "ops"]) + check("and the count is right", + {r["name"]: r["task_count"] for r in registry["rows"]}, + {"Bug": 1, "ops": 1}) + + # ── One spelling per tag ─────────────────────────────────────────────── + after = await tasks_mod.patch_task(t2, TaskIn(tags=["BUG", "bug"]), user=user) + check("a differently-cased tag is stored with the REGISTRY spelling", + after["tags"], ["Bug"]) + + registry = await tags_mod.list_tags(PID, user=user) + check("and no second tag was created", len(registry["rows"]), 2) + + # ── Filtering ────────────────────────────────────────────────────────── + await tasks_mod.patch_task(t3, TaskIn(tags=["ops"]), user=user) + check("tags= is ANY", titles(await ls(tags="Bug,ops")), + ["task 1", "task 2", "task 3"]) + check("tags_all= is ALL", titles(await ls(tags_all="Bug,ops")), ["task 1"]) + check("a tag nobody uses matches nothing", (await ls(tags="ghost")).total, 0) + + # ── Duplicate refused with the spelling that exists ──────────────────── + try: + await tags_mod.create_tag(PID, TagIn(name="bug"), user=user) + check("a duplicate tag is refused", "no error", "409") + except Exception as exc: + check("a duplicate tag is refused", getattr(exc, "status_code", None), 409) + check("and the refusal names the existing spelling", + "'Bug'" in str(getattr(exc, "detail", "")), True) + + # ── Rename ───────────────────────────────────────────────────────────── + by_name = {r["name"]: r for r in (await tags_mod.list_tags(PID, user=user))["rows"]} + renamed = await tags_mod.patch_tag( + by_name["Bug"]["id"], TagIn(name="defect", color="red"), user=user) + check("a rename reports how many tasks it retagged", renamed["retagged"], 2) + check("and recolours in the same call", renamed["color"], "red") + + fresh = await tasks_mod.get_task(t1, user=user) + check("the task now wears the new name", fresh["tags"], ["defect", "ops"]) + check("in the SAME position it had", fresh["tags"][0], "defect") + + check("and the old name finds nothing", (await ls(tags="Bug")).total, 0) + check("while the new one finds them", (await ls(tags="defect")).total, 2) + + # ── Rename onto an existing name is refused, not a silent merge ──────── + by_name = {r["name"]: r for r in (await tags_mod.list_tags(PID, user=user))["rows"]} + try: + await tags_mod.patch_tag(by_name["defect"]["id"], TagIn(name="ops"), user=user) + check("renaming onto an existing tag is refused", "no error", "409") + except Exception as exc: + check("renaming onto an existing tag is refused", + getattr(exc, "status_code", None), 409) + check("and it points at merge", + "erge" in str(getattr(exc, "detail", "")), True) + + # ── Merge ────────────────────────────────────────────────────────────── + merged = await tags_mod.merge_tag( + by_name["defect"]["id"], MergeIn(into_tag_id=by_name["ops"]["id"]), user=user) + check("merge reports what moved", merged["retagged"], 2) + + both = await tasks_mod.get_task(t1, user=user) + check("a task that carried BOTH ends with the target ONCE", both["tags"], ["ops"]) + only_source = await tasks_mod.get_task(t2, user=user) + check("a task that carried only the source gets the target", + only_source["tags"], ["ops"]) + + left = await tags_mod.list_tags(PID, user=user) + check("the source tag is gone", [r["name"] for r in left["rows"]], ["ops"]) + check("and the survivor's count is the union, not the sum", + left["rows"][0]["task_count"], 3) + + try: + await tags_mod.merge_tag( + left["rows"][0]["id"], MergeIn(into_tag_id=left["rows"][0]["id"]), + user=user) + check("a tag cannot be merged into itself", "no error", "422") + except Exception as exc: + check("a tag cannot be merged into itself", + getattr(exc, "status_code", None), 422) + + # ── Delete strips it from every task ─────────────────────────────────── + gone = await tags_mod.delete_tag(left["rows"][0]["id"], user=user) + check("delete reports how many tasks it untagged", + gone["cascaded"], {"tasks_untagged": 3}) + stripped = await tasks_mod.get_task(t1, user=user) + check("and the task really lost it", stripped["tags"], []) + check("the registry is empty again", + (await tags_mod.list_tags(PID, user=user))["total"], 0) + + # ── The unique index is real, not only a Python rule ─────────────────── + db = await get_db() + try: + await db.execute(text( + "INSERT INTO pm_tags (project_id, name, created_by) " + "VALUES (CAST(:p AS uuid), 'Bug', :me)"), {"p": PID, "me": ME}) + await db.commit() + try: + await db.execute(text( + "INSERT INTO pm_tags (project_id, name, created_by) " + "VALUES (CAST(:p AS uuid), 'bug', :me)"), {"p": PID, "me": ME}) + await db.commit() + check("the DATABASE refuses a differently-cased duplicate", + "inserted", "refused") + except Exception: + await db.rollback() + check("the DATABASE refuses a differently-cased duplicate", + "refused", "refused") + try: + await db.execute(text( + "INSERT INTO pm_tags (project_id, name, created_by) " + "VALUES (CAST(:p AS uuid), ' padded ', :me)"), {"p": PID, "me": ME}) + await db.commit() + check("the DATABASE refuses an untrimmed name", "inserted", "refused") + except Exception: + await db.rollback() + check("the DATABASE refuses an untrimmed name", "refused", "refused") + finally: + await db.close() + + print() + if failures: + print(f"{len(failures)} FAILED: {failures}") + raise SystemExit(1) + print("all live checks passed") + + +asyncio.run(main()) diff --git a/tests/live/live_ws27n.py b/tests/live/live_ws27n.py new file mode 100644 index 00000000..8aca0d6a --- /dev/null +++ b/tests/live/live_ws27n.py @@ -0,0 +1,256 @@ +"""WS-27n against a REAL Postgres. + +Bulk edit's whole risk is what happens across a MIXED selection — tasks in +different projects, tasks the caller cannot see, a status name that exists in +one project and not another. A fake agrees with itself about all of them. +""" +import asyncio +import os +import sys +import uuid + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import bulk as bulk_mod # noqa: E402 +from gateway.routes.projects import tags as tags_mod # noqa: E402 +from gateway.routes.projects import tasks as tasks_mod # noqa: E402 +from gateway.routes.projects.bulk import BulkIn # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +RAVI = "ravi@fracktal.in" +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def project(db, name, statuses, *, grant=True): + pid = str(uuid.uuid4()) + await db.execute(text( + "INSERT INTO pm_projects (id, name, source, created_by) " + "VALUES (CAST(:p AS uuid), :n, 'manual', :me)"), + {"p": pid, "n": name, "me": ME}) + if grant: + await db.execute(text( + "INSERT INTO pm_project_grants (project_id, subject, created_by) " + "VALUES (CAST(:p AS uuid), :s, :s)"), {"p": pid, "s": ME}) + lanes = {} + for i, (label, category) in enumerate(statuses, start=1): + sid = str(uuid.uuid4()) + lanes[label] = sid + await db.execute(text( + "INSERT INTO pm_task_statuses (id, project_id, name, position, " + "category, is_default) VALUES (CAST(:s AS uuid), CAST(:p AS uuid), " + ":n, :i, :c, :d)"), + {"s": sid, "p": pid, "n": label, "i": i, "c": category, "d": i == 1}) + return pid, lanes + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + # Ops knows "Done"; Firmware deliberately does NOT — a mixed selection + # spanning both is the case this ticket is about. + ops, ops_lanes = await project(db, "Ops", [("To do", "todo"), ("Done", "done")]) + fw, fw_lanes = await project(db, "Firmware", [("Open", "todo")]) + hidden, hidden_lanes = await project( + db, "Secret", [("To do", "todo")], grant=False) + + made = {} + n = 0 + for key, pid, lanes, lane in ( + ("ops1", ops, ops_lanes, "To do"), + ("ops2", ops, ops_lanes, "To do"), + ("ops3", ops, ops_lanes, "Done"), + ("fw1", fw, fw_lanes, "Open"), + ("hidden1", hidden, hidden_lanes, "To do"), + ): + n += 1 + tid = str(uuid.uuid4()) + made[key] = tid + await db.execute(text( + "INSERT INTO pm_tasks (id, project_id, root_project_id, status_id, " + "title, source, created_by, task_number, tags) VALUES " + "(CAST(:id AS uuid), CAST(:p AS uuid), CAST(:p AS uuid), " + "CAST(:s AS uuid), :t, 'manual', :me, :n, ARRAY[]::text[])"), + {"id": tid, "p": pid, "s": lanes[lane], "t": key, "me": ME, "n": n}) + # ops2 already has Ravi, to prove a re-assert is not a change. + await db.execute(text( + "INSERT INTO pm_task_assignees (task_id, assignee, assigned_by) " + "VALUES (CAST(:t AS uuid), :a, :me)"), + {"t": made["ops2"], "a": RAVI, "me": ME}) + await db.commit() + return made + finally: + await db.close() + + +async def task_row(tid, user): + return await tasks_mod.get_task(tid, user=user) + + +async def main(): + t = await seed() + user = UserContext(email=ME, role="member") + B = lambda **kw: BulkIn(**kw) # noqa: E731 + + # ── Shape refused before anything is written ─────────────────────────── + for label, payload in ( + ("an empty selection", B(task_ids=[], patch={"importance": 1})), + ("a request that asks for nothing", B(task_ids=[t["ops1"]])), + ("status_id instead of status", + B(task_ids=[t["ops1"]], patch={"status_id": str(uuid.uuid4())})), + ("an unknown field", B(task_ids=[t["ops1"]], patch={"colour": "red"})), + ): + try: + await bulk_mod.bulk_edit(payload, user=user) + check(f"{label} is refused", "no error", "422") + except Exception as exc: + check(f"{label} is refused", getattr(exc, "status_code", None), 422) + + before = await task_row(t["ops1"], user) + check("and nothing was written by any of them", before["importance"], None) + + # ── The happy path across one project ────────────────────────────────── + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"], t["ops2"]], patch={"importance": 3}), user=user) + check("both tasks changed", out["applied"], 2) + check("nothing failed", out["failed"], []) + check("importance really landed", + (await task_row(t["ops1"], user))["importance"], 3) + + # ── Already-in-state is SKIPPED, not a phantom edit ──────────────────── + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"], t["ops2"]], patch={"importance": 3}), user=user) + check("re-applying the same value changes nothing", out["applied"], 0) + check("and says why", sorted({s["reason"] for s in out["skipped"]}), ["unchanged"]) + + # ── A status NAME resolved per task's own project ────────────────────── + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"], t["fw1"]], patch={"status": "Done"}), user=user) + check("the task whose project HAS the lane moved", out["applied"], 1) + check("and the one whose project does not is reported per task", + [f["task_id"] for f in out["failed"]], [t["fw1"]]) + check("with the lanes that project actually has", + "Open" in out["failed"][0]["reason"], True) + check("the mover really moved", + (await task_row(t["ops1"], user))["completed_at"] is not None, True) + check("and the other was left exactly as it was", + (await task_row(t["fw1"], user))["completed_at"], None) + + # ── An invisible task is SKIPPED, not an error, and not a leak ───────── + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops2"], t["hidden1"]], patch={"importance": 1}), user=user) + check("the visible one was edited", out["applied"], 1) + check("the invisible one is skipped as not_found", + [s for s in out["skipped"] if s["task_id"] == t["hidden1"]], + [{"task_id": t["hidden1"], "reason": "not_found"}]) + check("and the batch did not fail", out["failed"], []) + + db = await get_db() + try: + untouched = (await db.execute(text( + "SELECT importance FROM pm_tasks WHERE id = CAST(:t AS uuid)"), + {"t": t["hidden1"]})).scalar() + check("the invisible task was genuinely not written", untouched, None) + finally: + await db.close() + + # ── Assignees ADD, not replace ───────────────────────────────────────── + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"], t["ops2"]], assignees_add=["Priya@Fracktal.IN"]), + user=user) + check("both got the new assignee", out["applied"], 2) + ops2 = await task_row(t["ops2"], user) + check("and the one that already had somebody KEPT them", + sorted(ops2["assignees"]), [ME, RAVI]) + + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops2"]], assignees_add=[RAVI]), user=user) + check("re-asserting an existing assignee is not a change", out["applied"], 0) + + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"], t["ops2"]], assignees_remove=[ME]), user=user) + check("remove takes them off", out["applied"], 2) + check("leaving the others", + (await task_row(t["ops2"], user))["assignees"], [RAVI]) + + # ── One notification per person per batch, not one per task ──────────── + db = await get_db() + try: + await db.execute(text("DELETE FROM pm_notifications")) + await db.commit() + finally: + await db.close() + + await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"], t["ops2"], t["ops3"]], assignees_add=[RAVI]), + user=user) + db = await get_db() + try: + rows = (await db.execute(text( + "SELECT recipient, excerpt FROM pm_notifications WHERE recipient = :r"), + {"r": RAVI})).fetchall() + check("three tasks assigned rings ONCE", len(rows), 1) + check("and the bell says how many", "other task" in (rows[0].excerpt or ""), True) + finally: + await db.close() + + # ── Tags go through the registry ─────────────────────────────────────── + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"], t["ops2"]], tags_add=["Bug", " needs review "]), + user=user) + check("tags applied to both", out["applied"], 2) + check("normalised on the way in", + sorted((await task_row(t["ops1"], user))["tags"]), ["Bug", "needs review"]) + + registered = await tags_mod.list_tags( + str((await task_row(t["ops1"], user))["root_project_id"]), user=user) + check("and REGISTERED, so bulk is not a second door into the array", + sorted(r["name"] for r in registered["rows"]), ["Bug", "needs review"]) + + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"]], tags_add=["BUG"]), user=user) + check("a differently-cased tag is not a second tag", out["applied"], 0) + + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"], t["ops2"]], tags_remove=["bug"]), user=user) + check("remove matches case-insensitively", out["applied"], 2) + check("and leaves the rest", + (await task_row(t["ops1"], user))["tags"], ["needs review"]) + + # ── Everything at once, which is the actual re-triage ────────────────── + out = await bulk_mod.bulk_edit( + B(task_ids=[t["ops1"], t["ops2"]], patch={"status": "Done", "importance": 0}, + assignees_add=[ME], tags_add=["triaged"]), + user=user) + check("one request does the whole re-triage", out["applied"], 2) + by_task = {r["task_id"]: sorted(r["changed"]) for r in out["results"]} + # ops2 was still in "To do", so it moves; ops1 was already "Done" from the + # earlier check, so its status legitimately does NOT appear. + check("the task that moved reports every axis it touched", by_task[t["ops2"]], + ["assignees", "importance", "status", "tags"]) + check("the one already in that lane reports the rest, without a phantom move", + by_task[t["ops1"]], ["assignees", "importance", "tags"]) + check("and the status name comes back so the UI need not re-read", + out["results"][0].get("status"), "Done") + + print() + if failures: + print(f"{len(failures)} FAILED: {failures}") + raise SystemExit(1) + print("all live checks passed") + + +asyncio.run(main()) diff --git a/tests/live/live_ws27o.py b/tests/live/live_ws27o.py new file mode 100644 index 00000000..140202d9 --- /dev/null +++ b/tests/live/live_ws27o.py @@ -0,0 +1,230 @@ +"""WS-27o against a REAL Postgres. + +The date maths is pure and tested hermetically. What only a real database can +answer: does closing a task actually spawn its successor, does closing it TWICE +spawn one, do the CHECKs refuse the rules they claim to, and does the successor +carry what it should. +""" +import asyncio +import os +import sys +import uuid +from datetime import UTC, datetime, timedelta + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import recurrence as rec_mod # noqa: E402 +from gateway.routes.projects import tasks as tasks_mod # noqa: E402 +from gateway.routes.projects.core import Page, TaskIn # noqa: E402 +from gateway.routes.projects.recurrence import RecurrenceIn # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +RAVI = "ravi@fracktal.in" +PID = "11111111-1111-1111-1111-111111111111" +TODO = "22222222-2222-2222-2222-222222222222" +DONE = "33333333-3333-3333-3333-333333333333" +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + await db.execute(text( + "INSERT INTO pm_projects (id,name,source,created_by) " + "VALUES (CAST(:p AS uuid),'Ops','manual',:me)"), {"p": PID, "me": ME}) + await db.execute(text( + "INSERT INTO pm_project_grants (project_id,subject,created_by) " + "VALUES (CAST(:p AS uuid),:s,:s)"), {"p": PID, "s": ME}) + for sid, name, cat, default in ( + (TODO, "To do", "todo", True), (DONE, "Done", "done", False), + ): + await db.execute(text( + "INSERT INTO pm_task_statuses (id,project_id,name,position,category," + "is_default) VALUES (CAST(:s AS uuid),CAST(:p AS uuid),:n,1,:c,:d)"), + {"s": sid, "p": PID, "n": name, "c": cat, "d": default}) + await db.commit() + finally: + await db.close() + + +async def make_task(title, *, due, tags=("ops",), assignees=(RAVI,)): + db = await get_db() + try: + tid = str(uuid.uuid4()) + n = int((await db.execute(text( + "INSERT INTO pm_task_counters (project_id,last_value) " + "VALUES (CAST(:p AS uuid),1) ON CONFLICT (project_id) DO UPDATE " + "SET last_value = pm_task_counters.last_value + 1 RETURNING last_value"), + {"p": PID})).scalar()) + await db.execute(text( + "INSERT INTO pm_tasks (id,project_id,root_project_id,status_id,title," + "description,importance,due_at,tags,custom_fields,source,created_by," + "task_number) VALUES (CAST(:i AS uuid),CAST(:p AS uuid),CAST(:p AS uuid)," + "CAST(:s AS uuid),:t,'the standing description',2,:due,:tags," + "CAST('{\"owner\":\"ops\"}' AS jsonb),'manual',:me,:n)"), + {"i": tid, "p": PID, "s": TODO, "t": title, "due": due, + "tags": list(tags), "me": ME, "n": n}) + for who in assignees: + await db.execute(text( + "INSERT INTO pm_task_assignees (task_id,assignee,assigned_by) " + "VALUES (CAST(:t AS uuid),:a,:me)"), {"t": tid, "a": who, "me": ME}) + await db.commit() + return tid + finally: + await db.close() + + +async def rows(sql, **p): + db = await get_db() + try: + return (await db.execute(text(sql), p)).fetchall() + finally: + await db.close() + + +async def main(): + await seed() + user = UserContext(email=ME, role="member") + yesterday = datetime.now(UTC) - timedelta(days=1) + + # ── Setting a rule ───────────────────────────────────────────────────── + t1 = await make_task("Weekly stock count", due=yesterday) + got = await rec_mod.set_recurrence( + t1, RecurrenceIn(freq="daily", interval=7, anchor="due"), user=user) + check("a rule can be set", got["rule"]["freq"], "daily") + check("and read back", (await rec_mod.get_recurrence(t1, user=user))["rule"]["interval"], 7) + + for label, payload in ( + ("an unknown frequency", RecurrenceIn(freq="fortnightly")), + ("a weekly rule with no weekdays", RecurrenceIn(freq="weekly")), + ("a monthly rule with no day", RecurrenceIn(freq="monthly")), + ("an interval of zero", RecurrenceIn(freq="daily", interval=0)), + ): + try: + await rec_mod.set_recurrence(t1, payload, user=user) + check(f"{label} is refused", "no error", "422") + except Exception as exc: + check(f"{label} is refused", getattr(exc, "status_code", None), 422) + + # ── Closing spawns the successor ─────────────────────────────────────── + before = len(await rows("SELECT id FROM pm_tasks")) + await tasks_mod.patch_task(t1, TaskIn(status_id=DONE), user=user) + after = await rows( + "SELECT * FROM pm_tasks WHERE id <> CAST(:t AS uuid) ORDER BY created_at DESC", + t=t1) + check("closing it created exactly one successor", len(await rows("SELECT id FROM pm_tasks")), + before + 1) + + successor = after[0] + check("the successor carries the title", successor.title, "Weekly stock count") + check("and the description", successor.description, "the standing description") + check("and the priority", successor.importance, 2) + check("and the tags", list(successor.tags), ["ops"]) + check("and the custom fields", successor.custom_fields, {"owner": "ops"}) + check("but starts in the DEFAULT lane, not Done", str(successor.status_id), TODO) + check("and is not already finished", successor.completed_at, None) + check("with a fresh task number", successor.task_number != 1, True) + check("and is due in the FUTURE", successor.due_at > datetime.now(UTC), True) + check("and stays in the same project", str(successor.project_id), PID) + + people = await rows( + "SELECT assignee FROM pm_task_assignees WHERE task_id = :t", t=successor.id) + check("the assignees came with it", [r.assignee for r in people], [RAVI]) + + # ── Closing TWICE does not spawn twice ───────────────────────────────── + count_now = len(await rows("SELECT id FROM pm_tasks")) + await tasks_mod.patch_task(t1, TaskIn(status_id=TODO), user=user) # reopen + await tasks_mod.patch_task(t1, TaskIn(status_id=DONE), user=user) # re-close + check("reopening and re-closing spawns nothing more", + len(await rows("SELECT id FROM pm_tasks")), count_now) + + stamped = await rows( + "SELECT recurrence_spawned_at FROM pm_tasks WHERE id = CAST(:t AS uuid)", t=t1) + check("because the spawn is stamped", stamped[0].recurrence_spawned_at is not None, True) + + # ── A task with no rule does nothing ─────────────────────────────────── + plain = await make_task("One-off", due=yesterday) + count_now = len(await rows("SELECT id FROM pm_tasks")) + await tasks_mod.patch_task(plain, TaskIn(status_id=DONE), user=user) + check("a task with no rule spawns nothing", + len(await rows("SELECT id FROM pm_tasks")), count_now) + + # ── The occurrence cap ends the series ───────────────────────────────── + t2 = await make_task("Three times only", due=yesterday) + await rec_mod.set_recurrence( + t2, RecurrenceIn(freq="daily", max_occurrences=1), user=user) + await tasks_mod.patch_task(t2, TaskIn(status_id=DONE), user=user) + made = await rows( + "SELECT occurrences_made FROM pm_recurrences WHERE id = (" + " SELECT recurrence_id FROM pm_tasks WHERE id = CAST(:t AS uuid))", t=t2) + check("the counter advanced", made[0].occurrences_made if made else None, 1) + + # The successor is now at the cap, so closing IT ends the series. + child = (await rows( + "SELECT id FROM pm_tasks WHERE title = 'Three times only' " + "AND id <> CAST(:t AS uuid)", t=t2))[0] + count_now = len(await rows("SELECT id FROM pm_tasks")) + await tasks_mod.patch_task(str(child.id), TaskIn(status_id=DONE), user=user) + check("at the cap, closing spawns nothing", + len(await rows("SELECT id FROM pm_tasks")), count_now) + + # ── Stopping a series keeps the work ─────────────────────────────────── + t3 = await make_task("Stoppable", due=yesterday) + await rec_mod.set_recurrence(t3, RecurrenceIn(freq="daily"), user=user) + gone = await rec_mod.clear_recurrence(t3, user=user) + check("stopping reports what it detached", gone["cascaded"]["tasks_detached"], 1) + still = await rows( + "SELECT id FROM pm_tasks WHERE id = CAST(:t AS uuid)", t=t3) + check("and the task itself survives", len(still), 1) + check("clearing again is not an error", + (await rec_mod.clear_recurrence(t3, user=user))["cleared"], False) + + # ── The database refuses what Python refuses ─────────────────────────── + db = await get_db() + try: + for label, sql in ( + ("a weekly rule with no weekdays", + "INSERT INTO pm_recurrences (project_id,freq,created_by) " + "VALUES (CAST(:p AS uuid),'weekly',:me)"), + ("a monthly rule with no day", + "INSERT INTO pm_recurrences (project_id,freq,created_by) " + "VALUES (CAST(:p AS uuid),'monthly',:me)"), + ("an interval of zero", + "INSERT INTO pm_recurrences (project_id,freq,interval,created_by) " + "VALUES (CAST(:p AS uuid),'daily',0,:me)"), + ("a weekday of 8", + "INSERT INTO pm_recurrences (project_id,freq,weekdays,created_by) " + "VALUES (CAST(:p AS uuid),'weekly',ARRAY[8]::smallint[],:me)"), + ): + try: + await db.execute(text(sql), {"p": PID, "me": ME}) + await db.commit() + check(f"the DATABASE refuses {label}", "inserted", "refused") + except Exception: + await db.rollback() + check(f"the DATABASE refuses {label}", "refused", "refused") + finally: + await db.close() + + print() + if failures: + print(f"{len(failures)} FAILED: {failures}") + raise SystemExit(1) + print("all live checks passed") + + +asyncio.run(main()) diff --git a/tests/live/live_ws27p.py b/tests/live/live_ws27p.py new file mode 100644 index 00000000..a60e1a02 --- /dev/null +++ b/tests/live/live_ws27p.py @@ -0,0 +1,190 @@ +"""WS-27p against a REAL Postgres. + +The cycle maths is pure and tested hermetically. What only a database can +answer: does the two-direction UNION actually run, does the visibility clause +scope the CHILDREN rather than inheriting from the parent, and does a link to a +task the reader cannot see stay hidden. +""" +import asyncio +import os +import sys +import uuid + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import relations as rel_mod # noqa: E402 +from gateway.routes.projects import tasks as tasks_mod # noqa: E402 +from gateway.routes.projects.tasks import LinkIn # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +OPEN_P = "11111111-1111-1111-1111-111111111111" +SECRET_P = "44444444-4444-4444-4444-444444444444" +TODO = "22222222-2222-2222-2222-222222222222" +DONE = "33333333-3333-3333-3333-333333333333" +SECRET_S = "55555555-5555-5555-5555-555555555555" +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + for pid, name, granted in ((OPEN_P, "Ops", True), (SECRET_P, "Secret", False)): + await db.execute(text( + "INSERT INTO pm_projects (id,name,source,created_by) " + "VALUES (CAST(:p AS uuid),:n,'manual',:me)"), + {"p": pid, "n": name, "me": ME}) + if granted: + await db.execute(text( + "INSERT INTO pm_project_grants (project_id,subject,created_by) " + "VALUES (CAST(:p AS uuid),:s,:s)"), {"p": pid, "s": ME}) + for sid, pid, name, cat, dflt in ( + (TODO, OPEN_P, "To do", "todo", True), + (DONE, OPEN_P, "Done", "done", False), + (SECRET_S, SECRET_P, "To do", "todo", True), + ): + await db.execute(text( + "INSERT INTO pm_task_statuses (id,project_id,name,position,category," + "is_default) VALUES (CAST(:s AS uuid),CAST(:p AS uuid),:n,1,:c,:d)"), + {"s": sid, "p": pid, "n": name, "c": cat, "d": dflt}) + await db.commit() + finally: + await db.close() + + +async def task(title, *, status=TODO, project=OPEN_P, parent=None): + db = await get_db() + try: + tid = str(uuid.uuid4()) + n = int((await db.execute(text( + "INSERT INTO pm_task_counters (project_id,last_value) VALUES " + "(CAST(:p AS uuid),1) ON CONFLICT (project_id) DO UPDATE SET " + "last_value = pm_task_counters.last_value + 1 RETURNING last_value"), + {"p": project})).scalar()) + await db.execute(text( + "INSERT INTO pm_tasks (id,project_id,root_project_id,status_id,title," + "parent_task_id,source,created_by,task_number) VALUES " + "(CAST(:i AS uuid),CAST(:p AS uuid),CAST(:p AS uuid),CAST(:s AS uuid)," + ":t,CAST(:par AS uuid),'manual',:me,:n)"), + {"i": tid, "p": project, "s": status, "t": title, "par": parent, + "me": ME, "n": n}) + await db.commit() + return tid + finally: + await db.close() + + +async def main(): + await seed() + user = UserContext(email=ME, role="member") + + parent = await task("Ship the firmware") + kid1 = await task("Write it", parent=parent) + kid2 = await task("Test it", parent=parent, status=DONE) + kid3 = await task("Ship it", parent=parent) + hidden_kid = await task("Classified step", parent=parent, + project=SECRET_P, status=SECRET_S) + + # ── Subtasks and progress ────────────────────────────────────────────── + got = await rel_mod.get_relations(parent, user=user) + check("subtasks are listed at all", len(got["subtasks"]), 3) + check("progress counts the finished one", got["progress"], {"done": 1, "total": 3}) + check("a subtask in a project the reader cannot see is NOT listed", + [s["title"] for s in got["subtasks"]], + ["Write it", "Test it", "Ship it"]) + check("and its title did not leak", + any("Classified" in s["title"] for s in got["subtasks"]), False) + check("the status NAME comes back so the panel need not re-read", + got["subtasks"][1]["status_name"], "Done") + + # ── Links, both directions ───────────────────────────────────────────── + await tasks_mod.create_link( + kid1, LinkIn(target_task_id=kid3, link_type="blocks"), user=user) + await tasks_mod.create_link( + kid1, LinkIn(target_task_id=kid2, link_type="relates_to"), user=user) + + from_kid1 = await rel_mod.get_relations(kid1, user=user) + outgoing = [x for x in from_kid1["links"] if x["direction"] == "outgoing"] + check("outgoing links come back", len(outgoing), 2) + check("kid1 blocks kid3", + [x["title"] for x in outgoing if x["link_type"] == "blocks"], ["Ship it"]) + check("and kid1 is blocked by nothing", from_kid1["blocked_by"], []) + + from_kid3 = await rel_mod.get_relations(kid3, user=user) + incoming = [x for x in from_kid3["links"] if x["direction"] == "incoming"] + check("the OTHER end sees it as incoming", len(incoming), 1) + check("kid3 is blocked by kid1", + [x["title"] for x in from_kid3["blocked_by"]], ["Write it"]) + + # ── A finished blocker stops blocking ────────────────────────────────── + from gateway.routes.projects.core import TaskIn + await tasks_mod.patch_task(kid1, TaskIn(status_id=DONE), user=user) + from_kid3 = await rel_mod.get_relations(kid3, user=user) + check("once the blocker is done, nothing is blocking", + from_kid3["blocked_by"], []) + check("but the LINK is still there — it is history, not a flag", + len([x for x in from_kid3["links"] if x["link_type"] == "blocks"]), 1) + + # ── The cycle guard, live ────────────────────────────────────────────── + a, b, c = await task("A"), await task("B"), await task("C") + await tasks_mod.create_link(a, LinkIn(target_task_id=b, link_type="blocks"), user=user) + await tasks_mod.create_link(b, LinkIn(target_task_id=c, link_type="blocks"), user=user) + + try: + await tasks_mod.create_link( + c, LinkIn(target_task_id=a, link_type="blocks"), user=user) + check("a three-hop cycle is refused", "created", "422") + except Exception as exc: + check("a three-hop cycle is refused", getattr(exc, "status_code", None), 422) + + try: + await tasks_mod.create_link( + b, LinkIn(target_task_id=a, link_type="blocks"), user=user) + check("a reciprocal block is refused", "created", "422") + except Exception as exc: + check("a reciprocal block is refused", getattr(exc, "status_code", None), 422) + + # relates_to is NOT directed, so a reciprocal one is fine. + await tasks_mod.create_link( + c, LinkIn(target_task_id=a, link_type="relates_to"), user=user) + check("but a reciprocal relates_to is allowed", + len((await rel_mod.get_relations(c, user=user))["links"]) >= 1, True) + + # ── A link to an invisible task cannot be created at all ─────────────── + secret = await task("Classified", project=SECRET_P, status=SECRET_S) + try: + await tasks_mod.create_link( + a, LinkIn(target_task_id=secret, link_type="blocks"), user=user) + check("linking to an unreadable task is refused", "created", "404") + except Exception as exc: + check("linking to an unreadable task is refused", + getattr(exc, "status_code", None), 404) + + # ── A task with nothing attached answers cleanly ─────────────────────── + lonely = await task("Nothing attached") + empty = await rel_mod.get_relations(lonely, user=user) + check("no subtasks is 0 of 0", empty["progress"], {"done": 0, "total": 0}) + check("and no links is an empty list, not null", empty["links"], []) + check("and nothing blocking", empty["blocked_by"], []) + + print() + if failures: + print(f"{len(failures)} FAILED: {failures}") + raise SystemExit(1) + print("all live checks passed") + + +asyncio.run(main()) diff --git a/tests/live/live_ws27q.py b/tests/live/live_ws27q.py new file mode 100644 index 00000000..00a73a4a --- /dev/null +++ b/tests/live/live_ws27q.py @@ -0,0 +1,188 @@ +"""WS-27q against a REAL Postgres. + +What only a database can answer: + +* does `CAST(t.start_date AS timestamp) AT TIME ZONE 'UTC'` actually parse and + produce a timestamptz comparable to a bound `datetime`? +* does asyncpg accept an aware `datetime` for `:window_from` with no CAST in + the statement to mislead its type inference? (It refused a `str` for + `due_before` under exactly those conditions in WS-27k.) +* does the interval overlap behave as claimed for the six real cases — before, + after, spanning, touching each edge, and neither date? +* is the ORDER BY over `coalesce(start_date, CAST(due_at AS date))` legal, given + the two branches have different types before the cast? +* does the session TimeZone actually NOT move the answer? +""" +import asyncio +import os +import sys +from datetime import UTC, date, datetime + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext, UserRole, build_access # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import calendar as cal_mod # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +P = "11111111-1111-1111-1111-111111111111" +TODO = "22222222-2222-2222-2222-222222222222" +DONE = "33333333-3333-3333-3333-333333333333" + +# title, start_date, due_at, status — August 2026 is the window. +TASKS = [ + ("due inside", None, "2026-08-14T10:00:00Z", TODO), + ("start only inside", "2026-08-03", None, TODO), + ("ended before", None, "2026-07-20T10:00:00Z", TODO), + ("starts after", "2026-09-10", None, TODO), + ("spans the window", "2026-06-01", "2026-12-01T00:00:00Z", TODO), + ("touches the first day", "2026-08-01", "2026-08-01T12:00:00Z", TODO), + ("due at the far edge", None, "2026-09-01T00:00:00Z", TODO), + ("due just inside the edge", None, "2026-08-31T23:59:00Z", TODO), + ("no dates at all", None, None, TODO), + ("closed and inside", None, "2026-08-20T10:00:00Z", DONE), + ("bar ending on day one", "2026-07-01", "2026-08-01T00:00:00Z", TODO), +] + +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + await db.execute(text( + "INSERT INTO pm_projects (id,name,source,created_by) " + "VALUES (CAST(:p AS uuid),'Ops','manual',:me)"), {"p": P, "me": ME}) + await db.execute(text( + "INSERT INTO pm_project_grants (project_id,subject,created_by) " + "VALUES (CAST(:p AS uuid),:s,:s)"), {"p": P, "s": ME}) + for sid, name, cat, dflt, pos in ( + (TODO, "To do", "todo", True, 10), + (DONE, "Done", "done", False, 40), + ): + await db.execute(text( + "INSERT INTO pm_task_statuses (id,project_id,name,position," + "category,is_default) VALUES (CAST(:s AS uuid),CAST(:p AS uuid)," + ":n,:pos,:c,:d)"), + {"s": sid, "p": P, "n": name, "pos": pos, "c": cat, "d": dflt}) + for n, (title, start, due, sid) in enumerate(TASKS, start=1): + await db.execute(text( + "INSERT INTO pm_tasks (id,project_id,root_project_id,status_id," + "title,task_number,start_date,due_at,created_by) " + "VALUES (gen_random_uuid(),CAST(:p AS uuid),CAST(:p AS uuid)," + "CAST(:s AS uuid),:ti,:n,CAST(:sd AS date),CAST(:du AS timestamptz)," + ":me)"), + {"p": P, "s": sid, "ti": title, "n": n, + "sd": date.fromisoformat(start) if start else None, + "du": datetime.fromisoformat(due.replace("Z", "+00:00")) + if due else None, + "me": ME}) + await db.commit() + finally: + await db.close() + + +def user(): + return UserContext( + email=ME, role=UserRole.EMPLOYEE, access=build_access(["*"]), + ) + + +async def august(**kwargs): + return await cal_mod.get_calendar( + user=user(), date_from="2026-08-01", date_to="2026-09-01", **kwargs, + ) + + +async def main(): + await seed() + result = await august() + titles = sorted(r["title"] for r in result["rows"]) + + check("the window returned the right set", titles, sorted([ + "due inside", + "start only inside", + "spans the window", + "touches the first day", + "due just inside the edge", + "closed and inside", + "bar ending on day one", + ])) + check("undated counted, not shown", result["undated"], 1) + check("not truncated", result["truncated"], False) + check("the window echoes back", (result["from"], result["to"]), + ("2026-08-01", "2026-09-01")) + + # A bar whose END is exactly the window's first instant is INSIDE (>=), and + # a due date exactly at the far edge is OUTSIDE (<). Both edges in one run. + check("far edge excluded", "due at the far edge" in titles, False) + check("near edge included", "bar ending on day one" in titles, True) + + filtered = await august(status_category="todo") + check("the board's filter applies", + "closed and inside" in {r["title"] for r in filtered["rows"]}, False) + + # The badges WS-27s added must survive onto the calendar's rows. + check("chips have their badge data", + all("subtasks" in r and "blocked_by_count" in r and "assignees" in r + for r in result["rows"]), True) + + # Ordering must be stable and by the interval's START — earliest first. + # "spans the window" starts 2026-06-01, "bar ending on day one" 2026-07-01. + check("ordered by the interval start", + [r["title"] for r in result["rows"]][:2], + ["spans the window", "bar ending on day one"]) + # And the coalesce must fall back to the due date for a task with no start, + # rather than sorting every start-less task to one end. + check("a start-less task sorts by its due date", + [r["title"] for r in result["rows"]].index("due inside") + < [r["title"] for r in result["rows"]].index("closed and inside"), + True) + + # The session TimeZone must not move the answer. `CAST(… AS timestamptz)` + # would; `AT TIME ZONE 'UTC'` must not. + db = await get_db() + try: + await db.execute(text("SET TIME ZONE 'America/Los_Angeles'")) + rows = (await db.execute( + text( + "SELECT t.title FROM pm_tasks t " + f"WHERE {cal_mod.OVERLAPS} ORDER BY t.task_number" + ), + {"window_from": datetime(2026, 8, 1, tzinfo=UTC), + "window_to": datetime(2026, 9, 1, tzinfo=UTC)}, + )).fetchall() + check("a hostile session TimeZone does not move the window", + sorted(r.title for r in rows), titles) + finally: + await db.close() + + # A window wider than the maximum, and a malformed one, both from the route. + for label, kwargs in ( + ("too wide", {"date_from": "2026-01-01", "date_to": "2030-01-01"}), + ("backwards", {"date_from": "2026-09-01", "date_to": "2026-08-01"}), + ("nonsense", {"date_from": "august", "date_to": "2026-09-01"}), + ): + try: + await cal_mod.get_calendar(user=user(), **kwargs) + check(f"{label} refused", "no error", "422") + except Exception as exc: # noqa: BLE001 + check(f"{label} refused", getattr(exc, "status_code", type(exc)), 422) + + print("\n" + ("FAILURES: " + ", ".join(failures) if failures else "all green")) + return 1 if failures else 0 + + +sys.exit(asyncio.run(main())) diff --git a/tests/live/live_ws27r.py b/tests/live/live_ws27r.py new file mode 100644 index 00000000..36dc4ffb --- /dev/null +++ b/tests/live/live_ws27r.py @@ -0,0 +1,155 @@ +"""WS-27r against a REAL Postgres. + +What only a database can answer: + +* does asyncpg bind a Python `None` for `:number` when the statement compares + it to a BIGINT column three times? (A NULL with no inferable type is exactly + the shape that has failed twice in this app.) +* is `ORDER BY rank` legal, given `rank` is also a window-function name? +* does the backslash actually work as LIKE's escape on a BOUND parameter, + which is where `standard_conforming_strings` could have interfered? +* does the three-table join keep the visibility clause's `t.` alias in scope? +""" +import asyncio +import os +import sys + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext, UserRole, build_access # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import search as search_mod # noqa: E402 +from gateway.routes.projects import tasks as tasks_mod # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +OPEN_P = "11111111-1111-1111-1111-111111111111" +SECRET_P = "44444444-4444-4444-4444-444444444444" +TODO = "22222222-2222-2222-2222-222222222222" +SECRET_S = "55555555-5555-5555-5555-555555555555" + +# title, description, number, archived +TASKS = [ + ("Parser rewrite", None, 1, False), + ("Fix the parser crash", None, 2, False), + ("Unrelated work", "the parser is mentioned only here", 3, False), + ("Rename task_id everywhere", None, 4, False), + ("Rename taskXid everywhere", None, 5, False), + ("Cut latency 50% by Friday", None, 6, False), + ("Ship 500 units", None, 7, False), + ("Old parser work", None, 8, True), + ("Answer to everything", None, 42, False), +] +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + for pid, name, granted in ((OPEN_P, "Ops", True), (SECRET_P, "Secret", False)): + await db.execute(text( + "INSERT INTO pm_projects (id,name,source,created_by) " + "VALUES (CAST(:p AS uuid),:n,'manual',:me)"), + {"p": pid, "n": name, "me": ME}) + if granted: + await db.execute(text( + "INSERT INTO pm_project_grants (project_id,subject,created_by) " + "VALUES (CAST(:p AS uuid),:s,:s)"), {"p": pid, "s": ME}) + for sid, pid in ((TODO, OPEN_P), (SECRET_S, SECRET_P)): + await db.execute(text( + "INSERT INTO pm_task_statuses (id,project_id,name,position," + "category,is_default) VALUES (CAST(:s AS uuid),CAST(:p AS uuid)," + "'To do',10,'todo',true)"), {"s": sid, "p": pid}) + for title, body, number, archived in TASKS: + await db.execute(text( + "INSERT INTO pm_tasks (id,project_id,root_project_id,status_id," + "title,description,task_number,archived_at,created_by) " + "VALUES (gen_random_uuid(),CAST(:p AS uuid),CAST(:p AS uuid)," + f"CAST(:s AS uuid),:ti,:d,:n,{'now()' if archived else 'NULL'},:me)"), + {"p": OPEN_P, "s": TODO, "ti": title, "d": body, "n": number, + "me": ME}) + await db.execute(text( + "INSERT INTO pm_tasks (id,project_id,root_project_id,status_id," + "title,task_number,created_by) VALUES (gen_random_uuid()," + "CAST(:p AS uuid),CAST(:p AS uuid),CAST(:s AS uuid)," + "'Confidential parser rewrite',1,:me)"), + {"p": SECRET_P, "s": SECRET_S, "me": ME}) + await db.commit() + finally: + await db.close() + + +def user(): + return UserContext( + email=ME, role=UserRole.EMPLOYEE, access=build_access(["feature:projects"]), + ) + + +async def main(): + await seed() + + hits = await search_mod.search_tasks(q="parser", user=user()) + titles = [r["title"] for r in hits["rows"]] + check("relevance order", titles, [ + "Parser rewrite", # rank 1 — title prefix + "Fix the parser crash", # rank 2 — title contains + "Unrelated work", # rank 3 — description only + ]) + check("archived not findable", "Old parser work" in titles, False) + check("an ungranted project is unreachable", + "Confidential parser rewrite" in titles, False) + check("the project is named", hits["rows"][0]["project_name"], "Ops") + check("not truncated", hits["truncated"], False) + + # A NULL bound for :number, compared to a BIGINT three times. + check("a word query runs at all", len(hits["rows"]), 3) + + numbered = await search_mod.search_tasks(q="#42", user=user()) + check("the exact number ranks first", + (numbered["rows"][0]["title"], numbered["rows"][0]["rank"]), + ("Answer to everything", 0)) + + underscore = await search_mod.search_tasks(q="task_id", user=user()) + check("an underscore is literal", + [r["title"] for r in underscore["rows"]], + ["Rename task_id everywhere"]) + + percent = await search_mod.search_tasks(q="50%", user=user()) + check("a percent is literal", + [r["title"] for r in percent["rows"]], + ["Cut latency 50% by Friday"]) + + short = await search_mod.search_tasks(q="p", user=user()) + check("a one-character query is empty", short["rows"], []) + + capped = await search_mod.search_tasks(q="e", limit=2, user=user()) + check("a one-char query is empty even with a limit", capped["rows"], []) + + small = await search_mod.search_tasks(q="re", limit=1, user=user()) + check("a cap of one truncates and says so", + (len(small["rows"]), small["truncated"]), (1, True)) + + # The list endpoint's own `q` must be escaped too — that was the live bug. + from gateway.routes.projects.core import Page + listed = await tasks_mod.list_tasks( + user=user(), q="task_id", page=Page(page=1, page_size=50), + ) + check("the LIST endpoint escapes too", + sorted(r["title"] for r in listed.rows), ["Rename task_id everywhere"]) + + print("\n" + ("FAILURES: " + ", ".join(failures) if failures else "all green")) + return 1 if failures else 0 + + +sys.exit(asyncio.run(main())) diff --git a/tests/live/live_ws27s.py b/tests/live/live_ws27s.py new file mode 100644 index 00000000..39c7ccc3 --- /dev/null +++ b/tests/live/live_ws27s.py @@ -0,0 +1,146 @@ +"""WS-27s against a REAL Postgres. + +The badge maths is trivial. What only a database can answer: do the two +aggregates actually PARSE and RUN — `count(*) FILTER (WHERE …)`, an aggregate +over `= ANY(CAST(:ids AS uuid[]))` with a list of Python strings, and a +`GROUP BY` whose rows have to be matched back to the page by string id. + +asyncpg has bitten this project twice on exactly that last point: it infers a +bound parameter's type from a surrounding CAST and refuses a mismatched Python +type. +""" +import asyncio +import os +import sys + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext, UserRole, build_access # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import tasks as tasks_mod # noqa: E402 +from gateway.routes.projects.core import Page # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +P = "11111111-1111-1111-1111-111111111111" +TODO = "22222222-2222-2222-2222-222222222222" +DONE = "33333333-3333-3333-3333-333333333333" +PARENT = "aaaaaaaa-0000-0000-0000-000000000001" +KID_DONE = "aaaaaaaa-0000-0000-0000-000000000002" +KID_OPEN = "aaaaaaaa-0000-0000-0000-000000000003" +KID_GONE = "aaaaaaaa-0000-0000-0000-000000000004" +BLOCKED = "bbbbbbbb-0000-0000-0000-000000000001" +BLOCKER_OPEN = "bbbbbbbb-0000-0000-0000-000000000002" +BLOCKER_DONE = "bbbbbbbb-0000-0000-0000-000000000003" +LONELY = "cccccccc-0000-0000-0000-000000000001" + +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + await db.execute(text( + "INSERT INTO pm_projects (id,name,source,created_by) " + "VALUES (CAST(:p AS uuid),'Ops','manual',:me)"), {"p": P, "me": ME}) + await db.execute(text( + "INSERT INTO pm_project_grants (project_id,subject,created_by) " + "VALUES (CAST(:p AS uuid),:s,:s)"), {"p": P, "s": ME}) + for sid, name, cat, dflt, pos in ( + (TODO, "To do", "todo", True, 10), + (DONE, "Done", "done", False, 40), + ): + await db.execute(text( + "INSERT INTO pm_task_statuses (id,project_id,name,position," + "category,is_default) VALUES (CAST(:s AS uuid),CAST(:p AS uuid)," + ":n,:pos,:c,:d)"), + {"s": sid, "p": P, "n": name, "pos": pos, "c": cat, "d": dflt}) + rows = ( + (PARENT, TODO, "Ship it", None, None), + (KID_DONE, DONE, "One", PARENT, None), + (KID_OPEN, TODO, "Two", PARENT, None), + (KID_GONE, TODO, "Dropped", PARENT, "now()"), + (BLOCKED, TODO, "Waiting", None, None), + (BLOCKER_OPEN, TODO, "Open blocker", None, None), + (BLOCKER_DONE, DONE, "Shipped blocker", None, None), + (LONELY, TODO, "Alone", None, None), + ) + for n, (tid, sid, title, parent, archived) in enumerate(rows, start=1): + await db.execute(text( + "INSERT INTO pm_tasks (id,project_id,root_project_id,status_id," + "title,task_number,parent_task_id,archived_at,created_by) " + "VALUES (CAST(:t AS uuid),CAST(:p AS uuid),CAST(:p AS uuid)," + f"CAST(:s AS uuid),:ti,:n,CAST(:par AS uuid)," + f"{archived or 'NULL'},:me)"), + {"t": tid, "p": P, "s": sid, "ti": title, "n": n, + "par": parent, "me": ME}) + for src in (BLOCKER_OPEN, BLOCKER_DONE): + await db.execute(text( + "INSERT INTO pm_task_links (source_task_id,target_task_id," + "link_type,created_by) VALUES (CAST(:s AS uuid)," + "CAST(:t AS uuid),'blocks',:me)"), + {"s": src, "t": BLOCKED, "me": ME}) + # A non-blocking link, both ways, must not count. + await db.execute(text( + "INSERT INTO pm_task_links (source_task_id,target_task_id," + "link_type,created_by) VALUES (CAST(:s AS uuid),CAST(:t AS uuid)," + "'relates_to',:me)"), + {"s": LONELY, "t": BLOCKED, "me": ME}) + await db.commit() + finally: + await db.close() + + +def user(email=ME): + return UserContext( + email=email, role=UserRole.EMPLOYEE, access=build_access(["*"]), + ) + + +async def main(): + await seed() + result = await tasks_mod.list_tasks(user=user(), page=Page(page=1, page_size=50)) + by_id = {str(r["id"]): r for r in result.rows} + + check("the page came back", len(by_id), 7) # KID_GONE is archived + check("parent progress", by_id[PARENT]["subtasks"], {"done": 1, "total": 2}) + check("blocked count", by_id[BLOCKED]["blocked_by_count"], 1) + check("a lonely task has zeros", + (by_id[LONELY]["subtasks"], by_id[LONELY]["blocked_by_count"]), + ({"done": 0, "total": 0}, 0)) + check("the open blocker is not itself blocked", + by_id[BLOCKER_OPEN]["blocked_by_count"], 0) + check("a child carries the keys too", by_id[KID_OPEN]["subtasks"], + {"done": 0, "total": 0}) + check("every row has both keys", + all("subtasks" in r and "blocked_by_count" in r for r in result.rows), + True) + + # Second page: the aggregates must be bounded to THIS page's ids, not to + # every task the filter matched. + page2 = await tasks_mod.list_tasks( + user=user(), page=Page(page=1, page_size=2), + ) + check("a short page still fills both keys", + all("subtasks" in r for r in page2.rows), True) + check("a short page is short", len(page2.rows), 2) + + # Assignees still attach — the two roll-ups run after that one. + check("assignees survived", "assignees" in by_id[PARENT], True) + + print("\n" + ("FAILURES: " + ", ".join(failures) if failures else "all green")) + return 1 if failures else 0 + + +sys.exit(asyncio.run(main())) diff --git a/tests/live/live_ws27t.py b/tests/live/live_ws27t.py new file mode 100644 index 00000000..dee90b78 --- /dev/null +++ b/tests/live/live_ws27t.py @@ -0,0 +1,132 @@ +"""WS-27t's backend half against a REAL Postgres. + +What only a database can answer: does `= ANY(CAST(:ids AS uuid[]))` on BOTH +ends of the same row actually run, does the relations read still work now that +it selects a DATE beside a timestamptz, and does an edge to a task the reader +cannot see stay hidden — the visibility question a new query always has to be +asked again, because `_WINDOW_LINKS_SQL` carries no visibility clause of its +own and relies entirely on its ids coming from an already-scoped set. +""" +import asyncio +import os +import sys +from datetime import UTC, date, datetime + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext, UserRole, build_access # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import calendar as cal_mod # noqa: E402 +from gateway.routes.projects import relations as rel_mod # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ME = "priya@fracktal.in" +OPEN_P = "11111111-1111-1111-1111-111111111111" +SECRET_P = "44444444-4444-4444-4444-444444444444" +TODO = "22222222-2222-2222-2222-222222222222" +SECRET_S = "55555555-5555-5555-5555-555555555555" +A = "aaaaaaaa-0000-0000-0000-00000000000a" +B = "aaaaaaaa-0000-0000-0000-00000000000b" +C = "aaaaaaaa-0000-0000-0000-00000000000c" +HIDDEN = "aaaaaaaa-0000-0000-0000-00000000000d" + +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + for pid, name, granted in ((OPEN_P, "Ops", True), (SECRET_P, "Secret", False)): + await db.execute(text( + "INSERT INTO pm_projects (id,name,source,created_by) " + "VALUES (CAST(:p AS uuid),:n,'manual',:me)"), + {"p": pid, "n": name, "me": ME}) + if granted: + await db.execute(text( + "INSERT INTO pm_project_grants (project_id,subject,created_by) " + "VALUES (CAST(:p AS uuid),:s,:s)"), {"p": pid, "s": ME}) + for sid, pid in ((TODO, OPEN_P), (SECRET_S, SECRET_P)): + await db.execute(text( + "INSERT INTO pm_task_statuses (id,project_id,name,position," + "category,is_default) VALUES (CAST(:s AS uuid),CAST(:p AS uuid)," + "'To do',10,'todo',true)"), {"s": sid, "p": pid}) + rows = ( + (A, OPEN_P, TODO, "A", "2026-08-03", "2026-08-07T17:00:00Z"), + (B, OPEN_P, TODO, "B", "2026-08-05", "2026-08-12T17:00:00Z"), + (C, OPEN_P, TODO, "C", "2026-08-20", None), + (HIDDEN, SECRET_P, SECRET_S, "Hidden", "2026-08-06", None), + ) + for n, (tid, pid, sid, title, start, due) in enumerate(rows, start=1): + await db.execute(text( + "INSERT INTO pm_tasks (id,project_id,root_project_id,status_id," + "title,task_number,start_date,due_at,created_by) " + "VALUES (CAST(:t AS uuid),CAST(:p AS uuid),CAST(:p AS uuid)," + "CAST(:s AS uuid),:ti,:n,CAST(:sd AS date)," + "CAST(:du AS timestamptz),:me)"), + {"t": tid, "p": pid, "s": sid, "ti": title, "n": n, + "sd": date.fromisoformat(start) if start else None, + "du": datetime.fromisoformat(due.replace("Z", "+00:00")) + if due else None, + "me": ME}) + for src, tgt in ((A, B), (B, C), (HIDDEN, C)): + await db.execute(text( + "INSERT INTO pm_task_links (source_task_id,target_task_id," + "link_type,created_by) VALUES (CAST(:s AS uuid)," + "CAST(:t AS uuid),'blocks',:me)"), {"s": src, "t": tgt, "me": ME}) + await db.commit() + finally: + await db.close() + + +def user(): + return UserContext( + email=ME, role=UserRole.EMPLOYEE, access=build_access(["feature:projects"]), + ) + + +async def main(): + await seed() + result = await cal_mod.get_calendar( + user=user(), date_from="2026-08-01", date_to="2026-09-01", + include_links=True, + ) + ids = {r["id"] for r in result["rows"]} + edges = {(e["blocker_id"], e["blocked_id"]) for e in result["links"]} + + check("only granted tasks in the window", ids, {A, B, C}) + check("both drawable edges came back", edges, {(A, B), (B, C)}) + check("an edge FROM an ungranted task is not drawn", + any(HIDDEN in pair for pair in edges), False) + check("but C still knows it is blocked", + next(r for r in result["rows"] if r["id"] == C)["blocked_by_count"], 2) + + without = await cal_mod.get_calendar( + user=user(), date_from="2026-08-01", date_to="2026-09-01", + ) + check("links absent unless asked for", without["links"], []) + + # The relations read now selects a DATE beside a timestamptz in a UNION ALL. + rel = await rel_mod.get_relations(B, user=user()) + incoming = [x for x in rel["links"] if x["direction"] == "incoming"] + check("relations carries the blocker's dates", + (incoming[0]["start_date"], incoming[0]["due_at"][:10]), + ("2026-08-03", "2026-08-07")) + check("a DATE round-trips as a bare date, not an instant", + "T" in str(incoming[0]["start_date"]), False) + + print("\n" + ("FAILURES: " + ", ".join(failures) if failures else "all green")) + return 1 if failures else 0 + + +sys.exit(asyncio.run(main())) diff --git a/tests/live/live_ws29.py b/tests/live/live_ws29.py new file mode 100644 index 00000000..d2d65834 --- /dev/null +++ b/tests/live/live_ws29.py @@ -0,0 +1,387 @@ +"""WS-29a + WS-29b against a REAL Postgres. Two organizations, real routes. + +What only a database can answer: + +* does `organization_id = CAST(:vis_org AS uuid)` bind a Python `str`, and a + Python `None`, without asyncpg complaining about the inferred type? +* does the recursive CTE still plan with a WHERE on the recursive term? +* does the BEFORE INSERT trigger actually FILL a NULL before NOT NULL is + checked (constraint order), and REFUSE a mismatched tenant? +* and the whole point: can tenant B reach ANY of tenant A's rows through the + real endpoint functions? +""" +import asyncio +import os +import sys + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext, UserRole, build_access # noqa: E402 +from fastapi import HTTPException # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.projects import core as pm_core # noqa: E402 +from gateway.routes.projects import calendar as pm_calendar # noqa: E402 +from gateway.routes.projects import me as pm_me # noqa: E402 +from gateway.routes.projects import notifications as pm_notes # noqa: E402 +from gateway.routes.projects import personal as pm_personal # noqa: E402 +from gateway.routes.projects import relations as pm_relations # noqa: E402 +from gateway.routes.projects import search as pm_search # noqa: E402 +from gateway.routes.projects import tasks as pm_tasks # noqa: E402 +from gateway.routes.projects import tree as pm_tree # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ANA = "ana@alpha.example" +BEN = "ben@beta.example" +BOSS = "boss@alpha.example" + +A_PROJ = "aaaaaaaa-0000-4000-8000-000000000001" +B_PROJ = "bbbbbbbb-0000-4000-8000-000000000001" +A_UNGRANTED = "aaaaaaaa-0000-4000-8000-000000000002" +A_STATUS = "aaaaaaaa-0000-4000-8000-000000000011" +B_STATUS = "bbbbbbbb-0000-4000-8000-000000000011" +A_TASK = "aaaaaaaa-0000-4000-8000-000000000021" +B_TASK = "bbbbbbbb-0000-4000-8000-000000000021" + +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +def member(email): + return UserContext(email=email, role=UserRole.EMPLOYEE, + access=build_access(["feature:projects"])) + + +def org_reader(email): + return UserContext(email=email, role=UserRole.EXECUTIVE, + access=build_access(["feature:projects", "data:org:read"])) + + +async def seed(): + db = await get_db() + try: + await db.execute(text("TRUNCATE pm_projects CASCADE")) + await db.execute(text( + "DELETE FROM app_user WHERE email IN (:a, :b, :c)"), + {"a": ANA, "b": BEN, "c": BOSS}) + await db.execute(text( + "DELETE FROM organization WHERE slug IN ('alpha', 'beta')")) + orgs = {} + for slug in ("alpha", "beta"): + row = (await db.execute(text( + "INSERT INTO organization (slug, display_name) " + "VALUES (:s, :s) RETURNING id"), {"s": slug})).fetchone() + orgs[slug] = str(row.id) + for email, slug in ((ANA, "alpha"), (BOSS, "alpha"), (BEN, "beta")): + await db.execute(text( + "INSERT INTO app_user (email, display_name, role, status, " + "organization_id) VALUES (:e, :e, 'employee', 'active', " + "CAST(:o AS uuid))"), {"e": email, "o": orgs[slug]}) + + # Two projects, granted IDENTICALLY (`subject = 'org'`). Anything that + # tells them apart afterwards can only be the tenant. + for pid, name, org, grant in ( + (A_PROJ, "Alpha work", "alpha", "org"), + (B_PROJ, "Beta work", "beta", "org"), + (A_UNGRANTED, "Alpha secret", "alpha", None), + ): + await db.execute(text( + "INSERT INTO pm_projects (id, name, source, created_by, " + "organization_id) VALUES (CAST(:p AS uuid), :n, 'manual', :who, " + "CAST(:o AS uuid))"), + {"p": pid, "n": name, "who": ANA, "o": orgs[org]}) + if grant: + # ⚠️ organization_id deliberately NOT supplied — the trigger + # must derive it from the project. + await db.execute(text( + "INSERT INTO pm_project_grants (project_id, subject, " + "created_by) VALUES (CAST(:p AS uuid), :s, :who)"), + {"p": pid, "s": grant, "who": ANA}) + for sid, pid in ((A_STATUS, A_PROJ), (B_STATUS, B_PROJ)): + await db.execute(text( + "INSERT INTO pm_task_statuses (id, project_id, name, position, " + "category, is_default) VALUES (CAST(:s AS uuid), " + "CAST(:p AS uuid), 'To do', 10, 'todo', true)"), + {"s": sid, "p": pid}) + for tid, pid, sid, title in ( + (A_TASK, A_PROJ, A_STATUS, "Quarterly margin review"), + (B_TASK, B_PROJ, B_STATUS, "Quarterly margin secrets"), + ): + await db.execute(text( + "INSERT INTO pm_tasks (id, project_id, root_project_id, " + "status_id, title, task_number, created_by) VALUES " + "(CAST(:t AS uuid), CAST(:p AS uuid), CAST(:p AS uuid), " + "CAST(:s AS uuid), :ti, 1, :who)"), + {"t": tid, "p": pid, "s": sid, "ti": title, "who": ANA}) + # ⚠️ Leak 3: Beta names Ana as an assignee on THEIR task. + await db.execute(text( + "INSERT INTO pm_task_assignees (task_id, assignee, assigned_by) " + "VALUES (CAST(:t AS uuid), :who, :by)"), + {"t": B_TASK, "who": ANA, "by": BEN}) + await db.commit() + return orgs + finally: + await db.close() + + +async def trigger_checks(orgs): + """The database half: FILL, REFUSE, and the root that cannot be invented.""" + db = await get_db() + try: + row = (await db.execute(text( + "SELECT organization_id FROM pm_project_grants g " + "WHERE g.project_id = CAST(:p AS uuid)"), {"p": A_PROJ})).fetchone() + check("a grant inherits its project's tenant", + str(row.organization_id), orgs["alpha"]) + row = (await db.execute(text( + "SELECT organization_id FROM pm_tasks WHERE id = CAST(:t AS uuid)"), + {"t": A_TASK})).fetchone() + check("a task inherits its project's tenant", + str(row.organization_id), orgs["alpha"]) + row = (await db.execute(text( + "SELECT organization_id FROM pm_task_assignees " + "WHERE task_id = CAST(:t AS uuid)"), {"t": B_TASK})).fetchone() + check("an assignee row inherits its task's tenant", + str(row.organization_id), orgs["beta"]) + finally: + await db.close() + + # A child claiming the WRONG tenant must be refused. + db = await get_db() + try: + await db.execute(text( + "INSERT INTO pm_task_statuses (project_id, name, position, " + "category, organization_id) VALUES (CAST(:p AS uuid), 'Sneak', 99, " + "'todo', CAST(:o AS uuid))"), {"p": A_PROJ, "o": orgs["beta"]}) + await db.commit() + check("a mismatched child tenant is refused", "accepted", "refused") + except Exception as exc: + check("a mismatched child tenant is refused", + "does not match" in str(exc), True) + finally: + await db.close() + + # A ROOT project with no tenant must be refused — nothing can derive it. + db = await get_db() + try: + await db.execute(text( + "INSERT INTO pm_projects (name, created_by) VALUES ('orphan', :w)"), + {"w": ANA}) + await db.commit() + check("a rootless project with no tenant is refused", + "accepted", "refused") + except Exception as exc: + check("a rootless project with no tenant is refused", + "not-null" in str(exc) or "null value" in str(exc), True) + finally: + await db.close() + + # A task whose ROOT lives in another organization — the second attachment. + db = await get_db() + try: + await db.execute(text( + "INSERT INTO pm_tasks (project_id, root_project_id, status_id, " + "title, task_number, created_by) VALUES (CAST(:a AS uuid), " + "CAST(:b AS uuid), CAST(:s AS uuid), 'straddle', 99, :w)"), + {"a": A_PROJ, "b": B_PROJ, "s": A_STATUS, "w": ANA}) + await db.commit() + check("a task straddling two organizations is refused", + "accepted", "refused") + except Exception as exc: + check("a task straddling two organizations is refused", + "does not match" in str(exc), True) + finally: + await db.close() + + +async def main(): + orgs = await seed() + await trigger_checks(orgs) + + ana, ben, boss = member(ANA), member(BEN), org_reader(BOSS) + + # ── ⚠️ Leak 1: `subject = 'org'` ──────────────────────────────────────── + listed = await pm_tree.list_nodes(user=ana) + check("ana's portfolio is alpha's only", + sorted(r["name"] for r in listed["rows"]), ["Alpha work"]) + listed = await pm_tree.list_nodes(user=ben) + check("ben's portfolio is beta's only", + sorted(r["name"] for r in listed["rows"]), ["Beta work"]) + + try: + await pm_tree.get_node(B_PROJ, user=ana) + check("an org grant does not cross the tenant", "visible", 404) + except HTTPException as exc: + check("an org grant does not cross the tenant", exc.status_code, 404) + + # ── ⚠️ Leak 2: `data:org:read` ────────────────────────────────────────── + listed = await pm_tree.list_nodes(user=boss) + check("data:org:read sees the whole ALPHA portfolio, ungranted included", + sorted(r["name"] for r in listed["rows"]), + ["Alpha secret", "Alpha work"]) + try: + await pm_tree.get_node(B_PROJ, user=boss) + check("data:org:read stops at the tenant", "visible", 404) + except HTTPException as exc: + check("data:org:read stops at the tenant", exc.status_code, 404) + + tasks = await pm_tasks.list_tasks(user=boss, page=pm_core.Page(1, 50)) + check("an org reader's task list stops at the tenant", + [r["title"] for r in tasks.rows], ["Quarterly margin review"]) + + # ── ⚠️ Leak 3: the assignee escape hatch ──────────────────────────────── + try: + await pm_tasks.get_task(B_TASK, user=ana) + check("being assigned in another tenant grants nothing", "visible", 404) + except HTTPException as exc: + check("being assigned in another tenant grants nothing", + exc.status_code, 404) + tasks = await pm_tasks.list_tasks(user=ana, page=pm_core.Page(1, 50)) + check("…and it does not appear in her list", + [r["title"] for r in tasks.rows], ["Quarterly margin review"]) + + # Search — the widest read, for both principals. + for who, label in ((ana, "member"), (boss, "org reader")): + hits = await pm_search.search_tasks(q="quarterly", user=who) + check(f"search stops at the tenant ({label})", + [r["title"] for r in hits["rows"]], ["Quarterly margin review"]) + + # A caller the directory does not know: fails CLOSED via `column = NULL`. + stranger = member("nobody@nowhere.example") + vis = None + db = await get_db() + try: + vis = await pm_core.resolve_visibility(db, stranger) + finally: + await db.close() + check("an unknown caller resolves to no tenant", vis.organization_id, None) + listed = await pm_tree.list_nodes(user=stranger) + check("…and sees nothing (NULL comparison, not an if)", + listed["rows"], []) + ghost = org_reader("ghost@nowhere.example") + listed = await pm_tree.list_nodes(user=ghost) + check("…even holding data:org:read", listed["rows"], []) + + # ── Writes ────────────────────────────────────────────────────────────── + created = await pm_tree.create_node(pm_tree.ProjectIn(name="Ana's new"), + user=ana) + db = await get_db() + try: + row = (await db.execute(text( + "SELECT organization_id FROM pm_projects WHERE id = CAST(:p AS uuid)"), + {"p": created["id"]})).fetchone() + check("a created root carries the caller's tenant", + str(row.organization_id), orgs["alpha"]) + # Everything the route seeded beneath it inherited the tenant. + for table, column in ( + ("pm_project_grants", "project_id"), + ("pm_task_statuses", "project_id"), + ("pm_task_types", "project_id"), + ("pm_views", "project_id"), + ): + wrong = (await db.execute(text( + f"SELECT count(*) FROM {table} WHERE {column} = CAST(:p AS uuid) " + f"AND organization_id <> CAST(:o AS uuid)"), + {"p": created["id"], "o": orgs["alpha"]})).scalar() + check(f"{table} seeded under it is in the same tenant", int(wrong), 0) + activities = (await db.execute(text( + "SELECT count(*) FROM pm_activities WHERE project_id = " + "CAST(:p AS uuid) AND organization_id = CAST(:o AS uuid)"), + {"p": created["id"], "o": orgs["alpha"]})).scalar() + check("the creation activity is in the same tenant", int(activities), 1) + finally: + await db.close() + + try: + await pm_tree.create_node( + pm_tree.ProjectIn(name="Wedge", parent_project_id=B_PROJ), user=ana) + check("grafting onto another tenant's project is 404", "created", 404) + except HTTPException as exc: + check("grafting onto another tenant's project is 404", + exc.status_code, 404) + + try: + await pm_tree.create_node(pm_tree.ProjectIn(name="Orphan"), + user=stranger) + check("a caller with no organization cannot create", "created", 403) + except HTTPException as exc: + check("a caller with no organization cannot create", + exc.status_code, 403) + + # Quick capture creates a personal ROOT project — the second decision point. + captured = await pm_personal.capture( + pm_personal.CaptureIn(title="Think about it"), user=ben) + db = await get_db() + try: + row = (await db.execute(text( + "SELECT p.organization_id FROM pm_projects p JOIN pm_tasks t " + "ON t.project_id = p.id WHERE t.id = CAST(:t AS uuid)"), + {"t": captured["id"]})).fetchone() + check("a capture's personal project is in the caller's tenant", + str(row.organization_id), orgs["beta"]) + # ⚠️ Nothing in `pm_*` may be left tenant-less. + for table in ( + "pm_projects", "pm_project_grants", "pm_task_statuses", + "pm_task_types", "pm_task_counters", "pm_tasks", + "pm_task_assignees", "pm_activities", "pm_views", + ): + null = (await db.execute(text( + f"SELECT count(*) FROM {table} WHERE organization_id IS NULL" + ))).scalar() + check(f"{table} has no tenant-less row", int(null), 0) + finally: + await db.close() + + # ── ⚠️ The two reads with NO grant clause ─────────────────────────────── + mine = await pm_me.assigned_to_me(user=ana, page=pm_core.Page(1, 50)) + check("assigned-to-me does not import beta's task", + [r["title"] for r in mine.rows], []) + inbox = await pm_personal.my_inbox(user=ana, page=pm_core.Page(1, 50)) + check("my/inbox does not import beta's task", + [r["title"] for r in inbox.rows], []) + # …and ben, who IS in beta, still sees his own captured work. + inbox = await pm_personal.my_inbox(user=ben, page=pm_core.Page(1, 50)) + check("ben still sees his own capture", + [r["title"] for r in inbox.rows], ["Think about it"]) + + # ── Every other consumer of `vis.params`, driven for the BIND ─────────── + # + # `vis.params` now always carries `vis_org`. A statement that does not name + # it, or names it without binding it, is an error only a real driver + # raises — the fake ignores unknown parameters by construction. + cal = await pm_calendar.get_calendar( + user=ana, date_from="2026-01-01", date_to="2026-12-31") + check("the calendar binds and stops at the tenant", + [r["title"] for r in cal["rows"]], []) + cal = await pm_calendar.get_calendar( + user=boss, date_from="2026-01-01", date_to="2026-12-31") + check("the calendar binds for data:org:read too", + [r["title"] for r in cal["rows"]], []) + notes = await pm_notes.list_notifications(user=ana, page=pm_core.Page(1, 50)) + check("notifications bind", notes["total"], 0) + rel = await pm_relations.get_relations(A_TASK, user=ana) + check("relations bind", (rel["subtasks"], rel["links"]), ([], [])) + try: + await pm_relations.get_relations(B_TASK, user=ana) + check("relations on another tenant's task is 404", "visible", 404) + except HTTPException as exc: + check("relations on another tenant's task is 404", exc.status_code, 404) + try: + await pm_relations.get_relations(B_TASK, user=boss) + check("…and 404 for data:org:read too", "visible", 404) + except HTTPException as exc: + check("…and 404 for data:org:read too", exc.status_code, 404) + + print("\n" + ("FAILURES: " + ", ".join(failures) if failures else "all green")) + return 1 if failures else 0 + + +sys.exit(asyncio.run(main())) diff --git a/tests/live/live_ws29e.py b/tests/live/live_ws29e.py new file mode 100644 index 00000000..7bf023e5 --- /dev/null +++ b/tests/live/live_ws29e.py @@ -0,0 +1,344 @@ +"""WS-29e (S1-1) against a REAL Postgres. Two organizations, two admins, the +real admin route functions. + +What only a database can answer: + +* does `organization_id = CAST(:org AS uuid)` bind a Python `str` on `app_user` + without asyncpg complaining about the inferred type? +* does `ON CONFLICT (email) DO UPDATE … WHERE` actually SKIP the arm — Postgres + is the only thing that can say whether the row was left alone or silently + rewritten, because the statement reports success either way? +* is `app_user_email_key` really global, i.e. does inviting another tenant's + address really CONFLICT rather than insert a second row? +* and the whole point: can admin B see, invite into, or grant a role in + organization A through the real route functions? +""" +import asyncio +import os +import sys + +os.environ["DATABASE_URL"] = ( + "postgresql+asyncpg://postgres@/cc?host=/var/tmp&port=55432" +) +sys.path.insert(0, "/home/user/CommandCenter/apps/services/gateway") + +from acb_auth import UserContext, UserRole, build_access # noqa: E402 +from fastapi import HTTPException # noqa: E402 +from gateway.db import get_db # noqa: E402 +from gateway.routes.admin import _common # noqa: E402 +from gateway.routes.admin import access_requests as ar # noqa: E402 +from gateway.routes.admin import groups as gr # noqa: E402 +from gateway.routes.admin import me as me_mod # noqa: E402 +from gateway.routes.admin import members as mb # noqa: E402 +from gateway.routes.admin import roles as rl # noqa: E402 +from sqlalchemy import text # noqa: E402 + +ANA = "ana@alpha.example" # admin of alpha +PRIYA = "priya@alpha.example" # member of alpha +BEN = "ben@beta.example" # admin of beta +BOB = "bob@beta.example" # member of beta +ORPHAN = "orphan@nowhere.example" # a row with NO organization_id (pre-130) +STRANGER = "nobody@nowhere.example" # no app_user row at all + +ADMIN_PERMS = [ + "admin:members:read", "admin:members:invite", + "admin:members:manage", "admin:access:manage", +] + +failures: list[str] = [] + + +def check(label, got, want): + ok = got == want + print(f"{'ok ' if ok else 'FAIL'} {label}: got {got!r}, want {want!r}") + if not ok: + failures.append(label) + + +def admin(email): + return UserContext(email=email, role=UserRole.EXECUTIVE, + access=build_access(ADMIN_PERMS, roles=["admin"])) + + +async def status_of(call): + """Run a route and report the HTTP status it produced (200 = accepted).""" + try: + await call + except HTTPException as exc: + return exc.status_code + return 200 + + +async def seed(): + db = await get_db() + try: + emails = (ANA, PRIYA, BEN, BOB, ORPHAN, STRANGER, "new@beta.example") + await db.execute( + text("DELETE FROM app_user WHERE email = ANY(:e)"), + {"e": list(emails)}, + ) + await db.execute( + text("DELETE FROM access_request WHERE email = ANY(:e)"), + {"e": list(emails)}, + ) + await db.execute( + text("DELETE FROM organization WHERE slug IN ('alpha', 'beta')")) + orgs = {} + for slug in ("alpha", "beta"): + row = (await db.execute(text( + "INSERT INTO organization (slug, display_name) " + "VALUES (:s, :s) RETURNING id"), {"s": slug})).fetchone() + orgs[slug] = str(row.id) + # Each tenant gets its own role rows — `org_role` is UNIQUE + # (organization_id, slug), so `owner` is a legal slug in both. + for role_slug, name, rank in ( + ("owner", "Owner", 0), ("admin", "Admin", 10), + ("member", "Member", 30), + ): + await db.execute(text( + "INSERT INTO org_role (organization_id, slug, display_name," + " is_system, rank) VALUES (CAST(:o AS uuid), :s, :n, true, :r)" + ), {"o": orgs[slug], "s": role_slug, "n": name, "r": rank}) + await db.execute(text( + "INSERT INTO org_group (organization_id, slug, display_name, " + "created_by) VALUES (CAST(:o AS uuid), 'people', 'People', 'seed')" + ), {"o": orgs[slug]}) + + for email, slug, role in ( + (ANA, "alpha", "owner"), (PRIYA, "alpha", "member"), + (BEN, "beta", "owner"), (BOB, "beta", "member"), + ): + row = (await db.execute(text( + "INSERT INTO app_user (email, display_name, role, status, " + "organization_id) VALUES (:e, :e, 'employee', 'active', " + "CAST(:o AS uuid)) RETURNING id"), + {"e": email, "o": orgs[slug]})).fetchone() + await db.execute(text( + "INSERT INTO user_role (user_id, role_id) SELECT :u, id " + "FROM org_role WHERE organization_id = CAST(:o AS uuid) " + "AND slug = :s"), + {"u": row.id, "o": orgs[slug], "s": role}) + + # ⚠️ A row with NO tenant — what migration 130 left behind. The fence's + # `IS NULL` arm is the only thing that lets it ever be provisioned. + await db.execute(text( + "INSERT INTO app_user (email, display_name, role, status) " + "VALUES (:e, :e, 'employee', 'invited')"), {"e": ORPHAN}) + + # A knock at the door: `access_request` has no tenant column, by design. + await db.execute(text( + "INSERT INTO access_request (email, display_name, status) " + "VALUES (:e, :e, 'pending')"), {"e": PRIYA}) + await db.commit() + return orgs + finally: + await db.close() + + +async def row_of(email): + db = await get_db() + try: + row = (await db.execute(text( + "SELECT organization_id::text AS org, status FROM app_user " + "WHERE lower(email) = :e"), {"e": email})).mappings().first() + return dict(row) if row else None + finally: + await db.close() + + +async def roles_of(email): + db = await get_db() + try: + rows = (await db.execute(text( + "SELECT r.slug FROM app_user u JOIN user_role ur ON ur.user_id = u.id" + " JOIN org_role r ON r.id = ur.role_id WHERE lower(u.email) = :e" + " ORDER BY r.slug"), {"e": email})).scalars().all() + return sorted(rows) + finally: + await db.close() + + +async def main(): + orgs = await seed() + a, b = admin(ANA), admin(BEN) + + # ── 1. The resolver, against real uuid binding ────────────────────────── + db = await get_db() + try: + check("get_org_id(ana) is alpha", + await _common.get_org_id(db, a), orgs["alpha"]) + check("get_org_id(ben) is beta", + await _common.get_org_id(db, b), orgs["beta"]) + check("a stranger is refused", + await status_of(_common.get_org_id(db, admin(STRANGER))), 403) + check("a caller with a NULL organization_id is refused", + await status_of(_common.get_org_id(db, admin(ORPHAN))), 403) + finally: + await db.close() + + # ── 2. SEE ───────────────────────────────────────────────────────────── + check("alpha's roster", + sorted(m.email for m in await mb.list_members(admin=a)), + [ANA, PRIYA]) + check("beta's roster", + sorted(m.email for m in await mb.list_members(admin=b)), + [BEN, BOB]) + check("ben reading alpha's member", + await status_of(mb.get_member_access(PRIYA, b)), 404) + check("/auth/me names ana's own org", + (await me_mod.get_me(user=a))["organization"].get("slug"), "alpha") + check("/auth/me names ben's own org", + (await me_mod.get_me(user=b))["organization"].get("slug"), "beta") + check("alpha's roles are alpha's", + sorted(r.slug for r in await rl.list_roles(admin=a)), + ["admin", "member", "owner"]) + check("alpha's groups are alpha's", + [g.slug for g in await gr.list_groups(admin=a)], ["people"]) + + # ── 3. INVITE INTO ───────────────────────────────────────────────────── + check("ben invites a new address into beta", + await status_of(mb.invite_member( + mb.InviteRequest(email="new@beta.example"), admin=b)), 200) + check("...and it landed in beta", + (await row_of("new@beta.example"))["org"], orgs["beta"]) + + check("ben invites ALPHA's member", + await status_of(mb.invite_member( + mb.InviteRequest(email=PRIYA, roles=["member"]), admin=b)), 404) + check("...priya is still in alpha", + (await row_of(PRIYA))["org"], orgs["alpha"]) + check("...priya is still active", (await row_of(PRIYA))["status"], "active") + check("...priya's roles are untouched", await roles_of(PRIYA), ["member"]) + + check("ben approves ALPHA's member from the shared queue", + await status_of(ar.approve_access_request( + PRIYA, ar.ApproveRequest(roles=["member"]), admin=b)), 404) + check("...priya is STILL in alpha", (await row_of(PRIYA))["org"], orgs["alpha"]) + + check("ben provisions the tenant-less legacy row", + await status_of(mb.invite_member( + mb.InviteRequest(email=ORPHAN), admin=b)), 200) + check("...and it was adopted into beta", + (await row_of(ORPHAN))["org"], orgs["beta"]) + + # ── 4. GRANT A ROLE IN ───────────────────────────────────────────────── + check("ben grants owner in alpha", + await status_of(mb.set_member_roles( + PRIYA, mb.RoleAssignment(roles=["owner"]), admin=b)), 404) + check("...priya is not an owner", await roles_of(PRIYA), ["member"]) + + check("ben writes an override on alpha's member", + await status_of(mb.set_member_overrides( + PRIYA, mb.OverrideRequest(overrides=[ + mb.OverrideEntry(permission="feature:email", effect="deny")]), + admin=b)), 404) + check("ben suspends alpha's member", + await status_of(mb.update_member( + PRIYA, mb.MemberPatch(status="suspended"), admin=b)), 404) + check("ben removes alpha's member", + await status_of(mb.remove_member(PRIYA, admin=b)), 404) + check("ben purges alpha's member", + await status_of(mb.purge_member(PRIYA, admin=b)), 404) + check("...priya survived all four", (await row_of(PRIYA))["status"], "active") + + check("ben adds alpha's member to beta's group", + await status_of(gr.add_group_member( + "people", gr.GroupMemberAdd(email=PRIYA), admin=b)), 404) + + # `org_group` is UNIQUE (organization_id, slug), so `people` exists in both. + check("ben renames HIS people group, not alpha's", + await status_of(gr.update_group( + "people", gr.GroupPatch(display_name="Beta People"), admin=b)), 200) + db = await get_db() + try: + rows = (await db.execute(text( + "SELECT o.slug AS org, g.display_name FROM org_group g " + "JOIN organization o ON o.id = g.organization_id " + "WHERE g.slug = 'people' AND o.slug IN ('alpha','beta') " + "ORDER BY o.slug"))).mappings().all() + check("...alpha's group is untouched", + {r["org"]: r["display_name"] for r in rows}, + {"alpha": "People", "beta": "Beta People"}) + finally: + await db.close() + + # ── 4b. Custom roles: reached by SLUG, which is unique per tenant ─────── + db = await get_db() + try: + await db.execute(text( + "INSERT INTO org_role (organization_id, slug, display_name, rank) " + "VALUES (CAST(:o AS uuid), 'auditor', 'Auditor', 25)"), + {"o": orgs["alpha"]}) + await db.commit() + finally: + await db.close() + check("ben edits alpha's custom role by slug", + await status_of(rl.update_role( + "auditor", rl.RolePatch(display_name="Pwned"), admin=b)), 404) + check("ben deletes alpha's custom role by slug", + await status_of(rl.delete_role("auditor", admin=b)), 404) + check("ben assigns alpha's role slug in his own org", + await status_of(mb.set_member_roles( + BOB, mb.RoleAssignment(roles=["auditor"]), admin=b)), 400) + + # ── 4c. ⚠️ The conflict is BYTE-exact; `find_member` is case-INsensitive ─ + # + # `app_user_email_key` is UNIQUE (email), not UNIQUE (lower(email)). A row + # stored with mixed case therefore does NOT conflict with its own lowercase + # form, and `provision_member` lowercases before the upsert. Only a real + # unique index can answer whether that is a second row. + db = await get_db() + try: + await db.execute(text("DELETE FROM app_user WHERE lower(email) = :e"), + {"e": "casey@alpha.example"}) + await db.execute(text( + "INSERT INTO app_user (email, display_name, role, status, " + "organization_id) VALUES ('Casey@Alpha.Example', 'Casey', " + "'employee', 'active', CAST(:o AS uuid))"), {"o": orgs["alpha"]}) + await db.commit() + finally: + await db.close() + check("ben invites alpha's member spelled in lower case", + await status_of(mb.invite_member( + mb.InviteRequest(email="casey@alpha.example"), admin=b)), 404) + db = await get_db() + try: + n = (await db.execute(text( + "SELECT count(*) FROM app_user WHERE lower(email) = :e"), + {"e": "casey@alpha.example"})).scalar() + check("...and there is still exactly ONE Casey", int(n), 1) + orgs_of_casey = (await db.execute(text( + "SELECT DISTINCT organization_id::text FROM app_user " + "WHERE lower(email) = :e"), {"e": "casey@alpha.example"})).scalars().all() + check("...still in alpha", sorted(orgs_of_casey), [orgs["alpha"]]) + finally: + await db.close() + + # ── 4d. The sign-in queue: shared by schema, and what that costs ─────── + knocks = [r.email for r in await ar.list_access_requests(admin=b)] + check("ben SEES alpha's pending knock (access_request has no tenant column)", + PRIYA in knocks, True) + check("ben can DENY alpha's knock", + await status_of(ar.deny_access_request(PRIYA, admin=b)), 200) + + # ── 5. The control: the same admin still runs their own tenant ───────── + check("ben suspends HIS OWN member", + await status_of(mb.update_member( + BOB, mb.MemberPatch(status="suspended"), admin=b)), 200) + check("...bob is suspended", (await row_of(BOB))["status"], "suspended") + check("ben grants a role in beta", + await status_of(mb.set_member_roles( + BOB, mb.RoleAssignment(roles=["admin"]), admin=b)), 200) + check("...bob is an admin of beta", await roles_of(BOB), ["admin"]) + check("ben reads HIS OWN member's resolved access", + (await mb.get_member_access(BOB, b))["email"], BOB) + # (purging your own member is WS-24's ticket and needs `audit_event`, + # which this box's migration set does not have — out of scope here.) + + print() + print("FAILURES:", failures or "none") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/live/prove_bootstrap.sh b/tests/live/prove_bootstrap.sh new file mode 100644 index 00000000..a05c3a7f --- /dev/null +++ b/tests/live/prove_bootstrap.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# WS-25 D1 — proving the two-stage bootstrap. +# +# The hazard: scripts/vps_apply.sh's first act is `git fetch && git reset --hard` +# on the very checkout it lives in. A box that runs it FROM the checkout has the +# file replaced underneath a bash process that is still reading it. +# +# bash reads a script incrementally: it parses one command, lseek()s the fd to +# just past that command, runs it, then reads onward FROM THAT OFFSET. So what +# happens next depends entirely on HOW the file was replaced: +# +# A1 replaced by RENAME (git's own method) -> the fd still points at the old +# inode. bash reads v1 to the end. No error, no garbage: the box runs the +# OLD deploy steps against the NEW tree and reports success. +# A2 replaced IN PLACE (truncate+write, same inode) -> bash resumes at v1's +# byte offset inside v2's bytes. Steps are skipped or torn in half. +# +# A1 is the quieter of the two and is the one git actually produces. Both are +# defeated by the same fix, which is why the two-stage bootstrap is not optional. +# +# B the REAL scripts/vps_pull.sh: read the target's copy out of the object +# database with `git show`, run it from a temp path nothing will touch. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)/bootstrap-proof" +REAL_PULL="/home/user/CommandCenter/scripts/vps_pull.sh" +rm -rf "$ROOT"; mkdir -p "$ROOT" +export GIT_AUTHOR_NAME=proof GIT_AUTHOR_EMAIL=p@x GIT_COMMITTER_NAME=proof GIT_COMMITTER_EMAIL=p@x + +# ── The two versions ───────────────────────────────────────────────────────── +# v1 carries a long comment banner that v2 deletes. Same 12 steps either way; +# only the byte offsets differ. Sized ~20 KB to match the real vps_apply.sh +# (26 KB) — comfortably past bash's read buffer, so re-reads definitely occur. +make_apply() { # $1=version $2=outfile $3=first-act(reset|inplace) + local v="$1" out="$2" act="$3" i + { + echo "# apply script v$v — stands in for scripts/vps_apply.sh" + if [ "$v" = 1 ]; then + for i in $(seq 1 300); do + echo "# banner line $i — 300 lines of WHY that exist in v1 and are deleted in v2." + done + fi + echo 'set -e' + echo 'cd "$APP_DIR"' + echo 'echo " [apply] STEP 0: synchronising the checkout (this rewrites me)"' + if [ "$act" = reset ]; then + echo 'git fetch --quiet origin release' + echo 'git reset --hard --quiet origin/release' + else + # Same net effect, but truncate-in-place instead of rename. + echo 'git fetch --quiet origin release' + echo 'git show origin/release:scripts/vps_apply.sh > scripts/vps_apply.sh' + fi + echo 'echo " [apply] inode after rewrite: $(stat -c %i scripts/vps_apply.sh)"' + for i in $(seq 1 12); do + echo "echo \" [apply] STEP $i of 12 — vVER\"" + done + echo 'echo " [apply] DONE — all 12 steps ran"' + } | sed "s/vVER/v$v/" > "$out" +} + +build_world() { # $1=first-act + rm -rf "$ROOT/origin.git" "$ROOT/seed" "$ROOT/box" "$ROOT/state" + git init -q --bare "$ROOT/origin.git" + git clone -q "$ROOT/origin.git" "$ROOT/seed" 2>/dev/null + mkdir -p "$ROOT/seed/scripts" + make_apply 1 "$ROOT/seed/scripts/vps_apply.sh" "$1" + git -C "$ROOT/seed" checkout -q -b release + git -C "$ROOT/seed" add -A; git -C "$ROOT/seed" commit -qm v1 + git -C "$ROOT/seed" push -q origin release + V1=$(git -C "$ROOT/seed" rev-parse HEAD) + git clone -q -b release "$ROOT/origin.git" "$ROOT/box" + make_apply 2 "$ROOT/seed/scripts/vps_apply.sh" "$1" + git -C "$ROOT/seed" commit -qam "v2 — banner deleted, steps relabelled" + git -C "$ROOT/seed" push -q origin release + V2=$(git -C "$ROOT/seed" rev-parse HEAD) +} + +hr() { printf '\n════════ %s ════════\n' "$*"; } + +# ═════════════════════════════════════════════════════════════════════════════ +hr "A1 — naive single-stage, rewrite by git reset --hard (git's real method)" +build_world reset +echo "v1=${V1:0:8} (20 KB) v2=${V2:0:8} (0.8 KB)" +echo "inode before: $(stat -c %i "$ROOT/box/scripts/vps_apply.sh")" +( cd "$ROOT/box" && APP_DIR="$ROOT/box" bash scripts/vps_apply.sh ) 2>&1 | sed 's/^/ A1| /' +echo " A1| exit=${PIPESTATUS[0]}" +echo " A1| checkout now at $(git -C "$ROOT/box" rev-parse --short HEAD) — but which version's steps ran?" + +# ═════════════════════════════════════════════════════════════════════════════ +hr "A2 — naive single-stage, rewrite IN PLACE (same inode)" +build_world inplace +echo "v1=${V1:0:8} v2=${V2:0:8}" +echo "inode before: $(stat -c %i "$ROOT/box/scripts/vps_apply.sh")" +( cd "$ROOT/box" && APP_DIR="$ROOT/box" bash scripts/vps_apply.sh ) 2>&1 | sed 's/^/ A2| /' +echo " A2| exit=${PIPESTATUS[0]}" + +# ═════════════════════════════════════════════════════════════════════════════ +hr "B — two-stage: the REAL /home/user/CommandCenter/scripts/vps_pull.sh" +build_world reset +rm -f /tmp/acb-vps-pull.lock +APP_DIR="$ROOT/box" RELEASE_REF=release STATE_DIR="$ROOT/state" \ + bash "$REAL_PULL" 2>&1 | sed 's/^/ B| /' +echo " B| exit=${PIPESTATUS[0]}" +echo " B| marker: $(cat "$ROOT/state/last-pull-ok" 2>/dev/null || echo MISSING) / $(cut -c1-8 "$ROOT/state/last-pull-sha" 2>/dev/null || echo MISSING)" + +# ═════════════════════════════════════════════════════════════════════════════ +# A3 — the spec's "executes garbage" case. Same in-place rewrite as A2, but v2 +# is SHIFTED rather than shortened: a few bytes inserted near the top push every +# later offset along, so bash resumes mid-line instead of past EOF. +hr "A3 — naive single-stage, in-place rewrite, v2 byte-SHIFTED" +rm -rf "$ROOT/origin.git" "$ROOT/seed" "$ROOT/box" +git init -q --bare "$ROOT/origin.git"; git clone -q "$ROOT/origin.git" "$ROOT/seed" 2>/dev/null +mkdir -p "$ROOT/seed/scripts" +gen() { # $1=shift-prefix + { echo "# apply script" + [ -n "$1" ] && echo "$1" + for i in $(seq 1 300); do echo "# banner line $i — 300 lines of WHY."; done + echo 'set -e'; echo 'cd "$APP_DIR"' + echo 'echo " [apply] STEP 0: synchronising the checkout (this rewrites me)"' + echo 'git fetch --quiet origin release' + echo 'git show origin/release:scripts/vps_apply.sh > scripts/vps_apply.sh' + for i in $(seq 1 12); do echo "echo \" [apply] STEP $i of 12\""; done + echo 'echo " [apply] DONE — all 12 steps ran"' + } > "$ROOT/seed/scripts/vps_apply.sh"; } +gen "" +git -C "$ROOT/seed" checkout -q -b release; git -C "$ROOT/seed" add -A +git -C "$ROOT/seed" commit -qm v1 >/dev/null; git -C "$ROOT/seed" push -q origin release +git clone -q -b release "$ROOT/origin.git" "$ROOT/box" +gen "# one extra comment line, inserted at the top of v2 — shifts every later byte offset" +git -C "$ROOT/seed" commit -qam v2 >/dev/null; git -C "$ROOT/seed" push -q origin release +( cd "$ROOT/box" && APP_DIR="$ROOT/box" bash scripts/vps_apply.sh ) 2>&1 | sed 's/^/ A3| /' +echo " A3| exit=${PIPESTATUS[0]}" From dbd2fecaf8e8aa25b30d8842334cb0862f48bd42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 06:24:31 +0000 Subject: [PATCH 19/22] docs: correct the commit count in HANDOVER.md A document whose whole value is being accurate about the branch should not be wrong about the size of the branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/HANDOVER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai-company-brain/HANDOVER.md b/ai-company-brain/HANDOVER.md index 90e17fb1..e6644a40 100644 --- a/ai-company-brain/HANDOVER.md +++ b/ai-company-brain/HANDOVER.md @@ -12,7 +12,7 @@ ## 1. Where the branch is -**16 commits ahead of `main`, tree clean, everything pushed.** Open PR **#399**. +**18 commits ahead of `main`, tree clean, everything pushed.** Open PR **#399**. | Verified on this branch | | |---|---| From 59f1d59a0ef55009b6f05af89d3fdc35fe2a5cb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 19:33:53 +0000 Subject: [PATCH 20/22] =?UTF-8?q?docs(WS-27):=20second=20PM=20reference=20?= =?UTF-8?q?studied=20=E2=80=94=20makeplane/plane=20v1.4.1=20(AGPL:=20patte?= =?UTF-8?q?rns=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner asked for plane to be learned and added as a reference beside Paca, with findings on what to lift, backend and UI/UX. Four parallel research passes (data model, API behaviors, web UI/UX, whole-product surfaces) over a shallow clone at 31853ab, every verdict-bearing claim spot-checked at its cited file:line by a second reader before synthesis. ## The license wall comes first Plane is AGPL-3.0-only, and that is categorically different from Paca's Apache-2.0: nothing may be copied or translated — patterns, shapes, and interaction designs only, re-derived in our own idiom. The research doc opens with that wall and every ticket sourced from it inherits the rule. (TipTap itself is MIT; plane's extensions of it are not.) ## What the research concluded `specs/plane_pm_research_2026-08.md` (reference-only, owns no work): - §2 — twelve of our shipped decisions validated against a second production codebase, with plane as the documented counterexample where it disagrees: per-view ordering (their single float cannot express our two-boards-two-orders requirement), trigger-enforced tenant key (theirs is ORM-only), the atomic counter (theirs is an advisory lock + a ledger table), cycle guards (they have NONE on relations), the single visibility predicate (they re-state guest filtering per endpoint), 422-over-fallback (their allowlists arrived after two order-by-injection CVEs; ours were day one). - §3–§4 — the gaps worth taking, ranked. Head of the queue: intake/triage (wrapper row + a triage status category excluded from default lists; accept-in-place, never copy), watchers + mention diffing (edits notify only NEW mentions), the archive guard (refuse archiving an open task), spreadsheet layout, kanban sub-grouping, the display-properties visibility contract over taskCard.ts, group-context quick-add. - §5 — refusals with reasons on record: modules (D-PM-8's exact rejected shape), estimate systems, the four-format collab description stack and the Node realtime sidecar, pervasive soft-delete, stickies, their vestigial importer. - §6 — two owner questions minted, deliberately undecided: public read-only boards (would be the FIRST anonymous tenant-data read route; capability-URL precedent analysed against PUBLIC_ROUTES and pooled RLS; default NO), and the project-docs ownership gap (PM assigns to Notes; Notes declines; now recorded instead of silently unowned). - §7 — where the two references disagree, and which side we take, so the next reference studied does not reopen settled questions. - §8 — consolidated P-1…P-31 verdict table. Annealed into project_management_app.md §11.19 (the beyond-parity queue), work_plan.md WS-27 row, and HANDOVER.md. Docs only — no code, no tests to run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/HANDOVER.md | 7 + .../specs/plane_pm_research_2026-08.md | 366 ++++++++++++++++++ .../specs/project_management_app.md | 53 +++ ai-company-brain/work_plan.md | 2 +- 4 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 ai-company-brain/specs/plane_pm_research_2026-08.md diff --git a/ai-company-brain/HANDOVER.md b/ai-company-brain/HANDOVER.md index f2e8469b..86e49691 100644 --- a/ai-company-brain/HANDOVER.md +++ b/ai-company-brain/HANDOVER.md @@ -59,6 +59,13 @@ dependencies, and a ⌘K search palette. **WS-29 (multi-tenancy) — started, and deliberately not finished.** See §3. +**Plane research (2026-08-09)** — second PM reference beside Paca: +`specs/plane_pm_research_2026-08.md`. ⚠️ AGPL-3.0 — patterns only, NEVER code (stricter +than Paca's Apache-2.0). The beyond-parity ticket queue (P-1…P-31) is in that doc §8 and +spec §11.19; the head of the queue is intake/triage, watchers, the archive guard, and the +spreadsheet view. The clone at `/workspace/makeplane/plane` is ephemeral to that sandbox — +re-clone shallow if you need to re-verify a citation. + ### ⚠️ 1.1 The first thing to do, before any ticket **Two migrations exist on this branch and are on no real database:** diff --git a/ai-company-brain/specs/plane_pm_research_2026-08.md b/ai-company-brain/specs/plane_pm_research_2026-08.md new file mode 100644 index 00000000..4591be96 --- /dev/null +++ b/ai-company-brain/specs/plane_pm_research_2026-08.md @@ -0,0 +1,366 @@ +# Plane PM-platform research — what to adopt, adapt, and refuse (2026-08) + +> **Product:** CommandCenter · **Concern:** second research appendix for the native +> project-management app (WS-27), beside `paca_pm_research_2026-08.md` · **Created:** +> 2026-08-09 · **Status:** 🟢 research complete — **reference-only, owns no work and no +> status**; adaptation verdicts are annealed into `specs/project_management_app.md` §11.19, +> which is the owning spec · **Owner:** vjvarada +> +> **Research provenance (2026-08-09):** +> - `makeplane/plane` @ `31853ab` (v1.4.1), shallow clone read at `/workspace/makeplane/plane` +> (ephemeral — re-clone with `GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 +> https://github.com/makeplane/plane`). Facts verified against the tree, not the README; +> every claim below that reached a verdict was spot-checked at its cited `file:line` by a +> second reader. +> - ⚠️ **LICENSE WALL — Plane is AGPL-3.0-only** (`LICENSE.txt`, SPDX headers per file). +> This is categorically different from Paca's Apache-2.0. **Nothing may be copied, +> translated, or paraphrased-at-the-code-level from this repository — patterns, shapes, +> and interaction designs only**, re-derived in our own idiom. A single lifted function +> would put the gateway under AGPL's network-copyleft. Everything in this document is +> deliberately written as behavioral description for that reason. (One nuance: Plane's +> *editor* builds on TipTap, which is itself MIT — the underlying library is usable; +> Plane's extensions of it are not.) +> - Four parallel research passes (data model · API behaviors · web UI/UX · whole-product +> surfaces), each verified against our tree before synthesis. Where a finding repeats +> across passes it appears once here, at its strongest. + +--- + +## 1. What Plane is, and why it maps onto us + +Plane is a production open-source Jira/Linear alternative: Django/DRF + Celery over +Postgres/Redis, a Next.js member app, and — this is its most interesting architectural +property — **four user-facing surfaces over one API**: `apps/web` (members), `apps/space` +(anonymous public boards, with its own separate view tree), `apps/admin` (instance +god-mode), `apps/live` (a Node Hocuspocus/Yjs server for collaborative page editing). + +Why it maps: it is the strongest available reference for **project management at product +maturity** — the features that appear only after years of real users (intake queues, +auto-archive policy, notification digests, webhook delivery hardening, per-user view +preferences, five layout types). Paca told us how agents join the table; Plane tells us +what the table looks like when a thousand teams have eaten at it. + +Why it does *not* map wholesale: Plane is workspace-flat (no container tree), orders +issues by a single float, has no relation-cycle guard, re-states its guest filter in every +endpoint, and pays a pervasive soft-delete tax. On each of those our existing design is +ahead, and §2 records the evidence so nobody trades down. + +## 2. Where Plane validates what we already built — keep, don't churn + +These are counterexamples and convergences, recorded so a future reader doesn't re-open +settled questions: + +| Ours | Plane's version | Verdict | +|---|---|---| +| **Per-view fractional ordering** (`pm_view_task_positions`, D-PM-5) | One `sort_order` float per issue, scoped per state (`issue.py:158,206-210`) — a task cannot sit in different orders on two boards | **KEEP OURS.** Plane is the documented counterexample; it cannot express our Center-slice vs People-board requirement. | +| **Tenant key filled + cross-checked by DB trigger** (migration 161) | Same denormalized `workspace_id` on every row, but stamped in ORM `save()` only (`project.py:180-189`) — raw SQL bypasses it, nothing refuses parent/child disagreement | **KEEP OURS.** Plane independently validates D-MT-3's carry-the-key shape; our fill-or-refuse trigger is the stronger mechanism. | +| **Atomic counter** — `INSERT … ON CONFLICT DO UPDATE … RETURNING` on `pm_task_counters` | `pg_advisory_xact_lock` + `MAX(sequence)` + a permanent `IssueSequence` ledger table (`issue.py:184-214`) | **KEEP OURS.** Same never-reuse guarantee, one statement, no lock choreography, no ledger. Theirs is an ORM workaround. | +| **Relation cycle guards** (`assert_no_block_cycle`, `assert_no_task_cycle`) | **None.** Their relation endpoint accepts any graph (`app/views/issue/relation.py:209-246`) | **KEEP OURS.** We are ahead of prior art here, not behind it. | +| **One visibility predicate** (`task_visibility_clause`, the single most dangerous line rule) | Guest filtering re-implemented per endpoint (`base.py:909-920`, `search/issue.py:141-144`, …) — every new endpoint must remember | **KEEP OURS.** Their repetition is the strongest available evidence for the single-predicate rule. | +| **404-never-403 (R5)** | Generic 403s; workspace-admin bypasses project checks (`permissions/base.py:64-84`) | **KEEP OURS.** Both conflict with our doctrine. | +| **Bulk: validate-all-then-apply, per-task outcomes** | Per-issue loop that queues activity events *before* a mid-loop abort — the log can claim work that never committed (`archive.py:305-341`) | **KEEP OURS.** Borrow only their machine-readable error codes in per-task outcomes. | +| **Page-batched aggregate attachers** (`filters.py` two-query pattern) | Correlated subqueries per row, gzip to compensate | **KEEP OURS**, and adopt the *requirement* framing: every new list badge must be page-batched, never per-row. | +| **422 on unknown filter/sort values** | Silent fallback to default sort; invalid uuids silently dropped from filters | **KEEP OURS.** Their allowlists were added *after* two order-by-injection CVEs (`order_queryset.py:15-16` cites GHSA-2r95-c453-vxmr) — ours were allowlists from day one. | +| **Statuses-as-data with semantic `category`; priority as a fixed enum** | Identical split: states are rows with a `group`, priority is a hard-coded 5-value enum (`issue.py:141-146`) | **CONVERGED.** Industry position confirmed from a second independent source. | +| **`completed_at` stamped in exactly one writer at the category boundary** | Same, in model `save()` (`issue.py:240-255`) — but note Plane does **not** stamp on `cancelled`; we do | **CONVERGED**, with the delta recorded: our analytics must distinguish done/cancelled by category, never by the timestamp alone. | +| **Agent-as-member identity** | Integrations act through a bot *user* with real membership + API token (`integration/base.py`) | **CONVERGED** with Paca §5 and our D-PM-4. Third independent source. | + +## 3. The backend gaps worth taking, ranked + +### 3.1 Intake / triage — the missing front door *(top pick)* + +The strongest transferable design in the repository, and it lands exactly on our §6.5 +email-to-task plan and the agent-created-task question. + +Shape (`intake.py:50-84`, `state.py:14-21`, `issue.py:92-101`): a submitted item **is a +real task from birth**, wrapped by a thin intake row carrying +`status ∈ {pending, rejected, snoozed, accepted, duplicate}`, `snoozed_till`, +`duplicate_to` (FK to the canonical task), `source`/`source_email`. The load-bearing +trick is a synthetic **triage** status category whose members are excluded from every +default query — un-triaged capture never pollutes a board, and *accepting is a status +flip, never a copy*, so provenance survives. Snoozed items drop out of the queue until +`snoozed_till`. + +For us: a `pm_intake` join table (not a column — a task can only be in intake once, and +the wrapper carries intake-only fields), a `triage` value in the status-category +vocabulary, one added predicate in the default list exclusion, and a triage rail in the +UI with four actions (accept / decline / mark-duplicate-of / snooze). Routing decisions +(auto-accept from trusted senders, agent screening) belong to `/workflows` per ADR-028/D6 +— the *states* live in PM, the *automation* lives in the engine. `duplicate_to` is the +disposition our personal-inbox vocabulary lacks today. + +### 3.2 Watchers + mention discipline — the collaboration primitive we skipped + +Three composable behaviors (`notification_task.py`, `issue.py:574-594`): + +1. **A subscribers table** (task ↔ member): the notification audience becomes + *subscribers*, not just assignees/mentioned. Anyone can watch a task they can see. +2. **Auto-subscribe on touch**: acting on a task (comment, edit, assign) subscribes the + actor — the people who touched a task keep hearing about it without opting in. +3. **Mention diffing**: on comment/description *edit*, mentions are set-differenced + against the previous content — **only new mentions notify**. A freshly-mentioned user + is excluded from the same event's subscriber fan-out so they get exactly one + "mentioned" notification, not mention + activity. Description edits never notify + subscribers at all. + +Our current audience is assignees + parsed `@address` targets, and an edited comment +re-notifies everyone. This is the cheapest genuinely-missing multiplayer piece: +`pm_task_watchers(task_id, watcher)` + the diff rule in the comment PATCH path. Our +visibility gate stays the stronger one (we check the recipient's grant closure via +`resolve_visibility_for`; Plane checks project membership only). + +### 3.3 Auto-archive / auto-close policy — two columns and a sweeper + +`Project.archive_in` / `close_in` (months, 0=off — `project.py:110-111`) + a nightly job +(`issue_automation_task.py`) that archives long-untouched closed tasks and closes stale +open ones to the project's default closing status. Two details worth keeping exactly: +automation-driven activity rows are flagged (`automation: true`) so timelines don't read +as human edits, and tasks inside an active cycle are exempt. + +For us: two nullable INTs on root `pm_projects`, the sweeper as a **`/workflows` +scheduled workflow** (ADR-028/D6 — a PM-app cron would be the second engine), and one +guard adopted immediately regardless: **manual archive refuses unless the task's status +category is done/cancelled** (`archive.py:257-263`) — an archived open task silently +exits every default list, which is a trap, not a feature. Directly serves post-ClickUp- +import hygiene: years of dead imported tasks age out without anyone gardening. + +### 3.4 Activity rows carry id *and* label for FK-valued fields + +`IssueActivity.old_value/new_value` hold display strings while `old_identifier/ +new_identifier` hold the UUIDs (`issue.py:415-438`). History survives status renames; +revert is exact. For us this is a **meta-shape rule, not a migration**: `field_change` +entries for status/parent/project must carry `{field, old_id, new_id, old_label, +new_label}`. Costs nothing now; makes §4's revert endpoint and the timeline immune to +lane renames. Companion behavior: **consecutive same-actor description edits coalesce** +(bump the previous activity's timestamp instead of appending — +`issue_activities_task.py:88-111`); autosaving editors otherwise write dozens of rows. + +### 3.5 List-read mechanics: semantic sort ranks, stable ties, picker exclusions + +- **Status sorts order by category rank** (backlog→todo→in_progress→done→cancelled), + never alphabetically; priority likewise (`order_queryset.py:150-169`). +- **Every ordering appends a deterministic tiebreaker** (`created_at, id`) so pagination + never straddles ties (`:186-192`). We should assert this structurally on `TASK_SORTS`. +- **Picker-context search exclusions** (`search/issue.py:37-83`): choosing a parent + excludes self + ancestors + descendants; choosing a relation excludes already-related + tasks in either direction. Our write-time cycle guards stay; the search API grows an + `exclude_relatives_of=` param so pickers can't offer what the write will 422. +- **Sub-task rollup gains a category distribution** beside `{done,total}` + (`sub_issue.py:171-201`) — the datum for a segmented progress ring; one grouped + aggregate, no denormalization. Hidden (archived) children stay excluded from rollups. + +### 3.6 Generic import provenance: `(external_source, external_id)` + +Plane carries the pair on every importable entity (`issue.py:162-163`, states, labels, +cycles, attachments), giving *every* importer idempotent upsert semantics +(`api/views/issue.py:616-646`) — where our `clickup_id` is single-provider. Adopt **at +the moment 161's named ticket widens the ClickUp constraint per-org anyway**: rename the +concept to `(external_source, external_id)`, `UNIQUE (organization_id, external_source, +external_id)`. The `clickup_snapshot`/`clickup_synced_at` columns stay ClickUp-specific +(they serve the merge, not identity). Their importer *framework*, by contrast, is a +vestige (moved to closed-source; no live routes) — our dry-run/mapping-plan/verify +approach is strictly better and stays. + +### 3.7 Patterns to bank for features we'll build later + +- **Cycles/sprints reference design** (for the reserved `pm_sprints`): membership is a + join table, not a column; **no burndown time-series table** — live burndown computes + from `completed_at`, and cycle close freezes totals + distributions into one + `progress_snapshot JSONB` (`cycle.py:74`, `cycle_transfer_issues.py:410-458`); closing + rolls incomplete tasks forward as an explicit, logged transfer. Our `completed_at` + column is already the entire data requirement. +- **Webhook delivery checklist** (when `/workflows` grows a webhook-out node): HMAC + signature header, per-delivery UUID, request+response log table, bounded retries with + jitter, auto-disable + owner email after final failure, retryable-vs-permanent + distinction, and **SSRF-pinned fetch** (resolve→validate→pin, never follow redirects — + `webhook_task.py:312-317`, closing the DNS-rebinding TOCTOU their GHSA cites). Tenant + URLs are hostile input; this list is complete and each item was learned from an + incident. +- **Email digest outbox**: in-app notifications write immediately; email writes an + outbox row, and a 5-minute sweep groups per receiver→task→actor into ONE digest + (`email_notification_task.py:46-85`). Preference flags gate the email channel only. + Never send-per-event. +- **Export jobs**: async job row (status, filters JSONB, unique token) → file → presigned + URL with 7-day expiry → daily cleanup sweeper (`exporter.py`, `export_task.py`). + Re-downloadable history. A filtered-list CSV/XLSX export is small and high-leverage. +- **Delta-sync feed** for agents/mobile: a list variant ordered by `updated_at` with + `updated_at__gt`, plus the prerequisite trick — satellite writes (comments, links, + assignees) bump the task's `updated_at` (`issue_activities_task.py:1532-1538`), or the + feed misses them. Their "cursor" is offset-in-costume — do not copy it as keyset. +- **`is_epic` flag on task types** (`issue_type.py:20`) instead of seeding-convention + identity — one line, makes the Epic-root rule enforceable without knowing seed names. +- **Project `timezone` column** (`project.py:116`) — gives the Gantt and any auto-close + sweeper a correct midnight; today we have nowhere to hang that. +- **Per-user view state**: shared `pm_views` stay canonical; a + `pm_view_user_state(view_id, member, config)` sibling holds each member's grouping/ + collapse state (Plane's `ProjectUserProperty` family, `project.py:342-369`). +- **Session rows carry a denormalized, indexed `user_id`** (`session.py`) — the whole + "list/revoke my sessions" feature is that one denormalization. For the control plane. + +## 4. The frontend gaps worth taking, ranked + +Verdicts here anneal into the UI work queue; each is an interaction spec, not a port. + +1. **Spreadsheet layout** — the missing fifth view. One row per task, one column per + card-field, every cell an inline editor, per-column sort in the header, sub-tasks + expand indented in-table, quick-add pinned to the bottom. Power users triage here. + Our custom fields map naturally to columns; the column set = the same visibility + contract as card chips (below). +2. **Kanban sub-grouping (swimlanes)** — `group_by` × `sub_group_by` (status columns × + assignee rows = the standup matrix), per-lane collapse, empty lanes hidden unless + asked for. Our grouping lib already computes both axes; the cross-product render is + the missing piece. +3. **Display-properties contract** — a per-view "shown fields" toggle set; every chip on + every card gates on it; the same key set drives spreadsheet columns and calendar + blocks. Plane unifies at the field-visibility level, we unify at the derived-data + level (`taskCard.ts`) — **combining both is better than either**: keep `taskCard.ts` + as the single fact layer, add the user-facing visibility contract on top, persist it + with the saved view. +4. **Quick-add in every group** — inline title-only row in each list group / kanban + column / calendar day, **pre-filled with the group's value** (adding under "In + Progress / Alice" creates it in-progress, assigned to Alice), Enter submits and + resets so you can keep typing. Highest-frequency action in the product. +5. **Peek escalation + focus return** — TaskPanel gains side-peek ↔ centered-modal ↔ + full-page sizes, and **Escape returns focus to the originating card** + (`view.tsx:104-113`) so keyboard flow survives open→close in long lists. +6. **Save/Update view affordances** — the applied-filters row compares live state to the + applied saved view (we already guarantee the `toConfig`/`fromConfig` round trip) and + conditionally offers Save view / **Update view** / Clear all. Makes view divergence + legible. +7. **Palette as action system** — keep our ranked search palette, add: an action + registry (create task, switch layout, go-to project, and mutate-open-task pickers: + status/assignee/priority *inside* the palette), two-key go-sequences (`g`+`h` home + style, 1s timeout), all shortcuts suppressed while typing in inputs, and a + shortcuts-help modal. Skip their URL-context machinery — we have one surface. +8. **Keyboard selection cursor** — ArrowUp/Down moves an active-row cursor, Shift+Arrow + extends selection from it, Enter opens the panel; feeds the existing BulkBar. +9. **Drop feedback** — when a drag can't drop (grouped by assignee, say), a translucent + overlay states *why* ("drop here would reassign — drag disabled"); after any drop or + quick-add, the moved card scrolls into view and flash-highlights. Pure feedback, + no write-model change; replaces our silent drag restriction. +10. **Calendar** — week layout beside month, weekend toggle, per-day quick-add + (due-date prefilled), per-day overflow ("+N more") instead of our whole-month + banner. We already have drag-between-days. +11. **Notifications inbox** — bell opens a two-pane inbox (list + embedded TaskPanel), + mark-read-on-open, tabs all/mentions with **separate unread counts** (the mention + badge is the high-signal one), snooze later. +12. **Human task IDs + copy-link** — we already allocate per-root numbers; surface them + (`KEY-42` style) on cards/panel with a copy-deep-link button. Makes tasks + referenceable in chat and commits — which our agent spine wants anyway. +13. **Timeline polish** — zoom presets (week/month/quarter as px-per-day steps), + drag-bar-edges to set dates, hover-a-dateless-row to place it with a 1-day default. + **Keep** our dependency arrows + warn-don't-reschedule (D-PM-12); Plane's OSS core + doesn't even render dependency arrows. **Refuse** their infinite-extend canvas — + our fixed filtered range is simpler and bounded. +14. **Small wins**: one-slot localStorage draft for the create form (restore on reopen); + click a progress-bar segment to apply that status-group filter; pin projects/views + to the tree top (flat, no folders); a capped recently-viewed list in MyWork; a tiered + `EmptyState` primitive in `ui/` (text-only vs text+CTA, tokens only). + +## 5. Refusals, with the reason on record + +- **Modules** (second M:N grouping axis): exactly what D-PM-8 rejected; costs four + tables per axis (join + user-props + links + favorites). Our subtree + multi-grant + already expresses deliverable grouping. +- **Estimate systems** (Estimate + EstimatePoint indirection): two tables and a join to + say "3 points" whose meaning mutates if the system is edited. `estimate_mins INT` + aggregates without interpretation. Recorded for the day someone asks for t-shirt sizes. +- **Four-format descriptions + full-row version snapshots + the live collab server**: + collaborative-editor infrastructure (Yjs binary canonical, HTML/JSON/stripped derived, + a Node sidecar delegating auth per-connection). A whole second realtime stack beside + AG-UI/SSE for a need neither the PM spec nor Notes has established. **Two lessons kept + even on refusal**: store derived forms beside the canonical one and regenerate on + every save (makes search/email/export free); and if we ever add rich text, start from + TipTap-the-MIT-library, markdown-stored, mention-autocomplete first — our + mention→notification wiring already exists, which is the hard part. +- **Pervasive soft-delete**: every query in their tree re-asserts `deleted_at IS NULL`; + a missed guard resurrects ghosts. Our archived-only posture stands. If any pm table + ever gains `deleted_at`, the non-obvious part to copy is **paired partial-unique + constraints** (`WHERE deleted_at IS NULL`) so re-creating a deleted name works. +- **Draft shadow table + `is_draft` flag** (two mechanisms for one concept — a scar, + not a pattern): our personal projects + one-slot form draft cover capture. +- **Comment threading + INTERNAL/EXTERNAL comment access**: serves their public-board + surface; not ours (yet — see §6). +- **Server-side grouped pagination** (RowNumber windows per group): correct pattern at + 10k-task boards; at our sizes client grouping over the filtered page is simpler. + Revisit only when a single board exceeds a few thousand tasks. +- **Their importer framework and integration registry**: vestigial in OSS (moved to + closed-source); our dry-run/mapping-plan importer is strictly better. +- **Stickies**: personal scratch notes are out of Projects scope; a project-less task + already covers it. + +## 6. Two questions this research raises for the owner (not decided here) + +**Q1 — Public read-only boards.** Plane publishes any container under a capability URL +(`anchor = uuid4().hex`, per-board kill switch, physically separate view tree + +serializers so the public surface is reviewable in one directory — `apps/space`, +`deploy_board.py`). A client-facing roadmap view is real product value. But for us it +would be **the first anonymous tenant-data READ route**: `/workflows/hooks/{token}` +established the capability-URL category for *writes into a rate-limited engine*; an +anchor route *streams org data out*. Under pooled RLS the handler must resolve +anchor→org **before** `SET LOCAL app.tenant_id` — one deliberate, auditable bypass. If +ever built: dedicated `routes/pm_public/` module with its own read-only models (never a +flag on member endpoints), no member-roster endpoint (Plane exposes member names/avatars +to anyone with the anchor — refuse that), per-board disable, rate limits, and a +leak-audit entry. The honest alternative is invite-as-restricted-guest. **Owner call; +default is NOT to build it.** + +**Q2 — Who owns project docs?** Plane's Pages (wiki with hierarchy, project attachment, +versions, an embed/backlink log) is their second-biggest surface. Our PM spec assigns +docs to Notes; `note_taker_app.md` §1.2 declares itself *not* a general document editor. +So **nobody currently owns free-form project documentation** — the gap is real and now +recorded in both specs' non-goals rather than silently unowned. + +A third, smaller: Plane's `guest_view_all_features=false` mode (guests see only tasks +they created) suggests a **restricted grant level** for contractors/clients — worth +holding until a real external collaborator shows up, then it's a grant attribute, not a +role. + +## 7. Where the two references disagree — and which side we take + +| Question | Paca | Plane | We take | +|---|---|---|---| +| Ordering | Per-view side table | One float per issue | **Paca** (built, D-PM-5) — Plane is the counterexample | +| Containers | One self-FK tree | Flat workspace→project | **Paca** (built) — departments/subprojects are real for us | +| Statuses | Rows + semantic category | Rows + semantic group | Both — converged | +| Agents/integrations as members | First-class thesis | Bot-user pattern | Both — converged (third source) | +| Task capture from outside | — (absent) | Intake/triage state machine | **Plane** (§3.1 — its biggest single contribution) | +| Sprint mechanics | — (absent) | Join table + snapshot-on-close | **Plane**, when sprints come (§3.7) | +| Layout breadth | List/board | +Spreadsheet, +sub-grouped kanban, +week calendar | **Plane** (§4) | +| Outbound webhooks / digests / exports | — (absent) | Hardened, incident-informed | **Plane**, as requirement checklists (§3.7) | + +## 8. Consolidated verdict table (annealed into `project_management_app.md` §11.19) + +| # | Item | Verdict | Where it lands | +|---|---|---|---| +| P-1 | Intake/triage (wrapper row, triage category, accept-in-place, duplicate_to, snooze) | **ADOPT** | new ticket candidate, pairs with §6.5 email capture | +| P-2 | Watchers table + auto-subscribe + mention diffing (edit notifies additions only) | **ADOPT** | notifications seam | +| P-3 | Archive guard (closed categories only) | **ADOPT now** | one predicate in the archive path | +| P-4 | `archive_in`/`close_in` columns + `/workflows` sweeper, automation-flagged activities | **ADOPT** | pm_projects + workflows | +| P-5 | Activity meta carries `{old_id,new_id,old_label,new_label}`; description-edit coalescing | **ADOPT** | `record_activity` meta rule | +| P-6 | Category-ranked status sort + deterministic `(created_at,id)` tiebreaker on every sort | **ADOPT** | `TASK_SORTS` | +| P-7 | Picker-context exclusions in search (`exclude_relatives_of`) | **ADOPT** | search.py | +| P-8 | Child category-distribution beside `{done,total}` | **ADOPT (when panel draws segments)** | relation counts attacher | +| P-9 | `(external_source, external_id)` generic provenance, per-org unique | **ADAPT at the 161-ticket moment** | importer identity | +| P-10 | Spreadsheet layout | **ADOPT** | biggest UI gap | +| P-11 | Kanban sub-grouping | **ADOPT** | board | +| P-12 | Display-properties visibility contract over `taskCard.ts` | **ADOPT** | shared card layer + saved views | +| P-13 | Group-context quick-add everywhere | **ADOPT** | all layouts | +| P-14 | Peek size escalation + Esc-returns-focus | **ADOPT** | TaskPanel | +| P-15 | Save/Update-view divergence affordances | **ADOPT** | FilterBar | +| P-16 | Palette action registry + go-sequences + shortcuts help | **ADAPT** | SearchPalette | +| P-17 | Keyboard selection cursor for bulk ops | **ADOPT** | selection lib | +| P-18 | Drop-refusal overlay with reason + post-drop flash | **ADOPT** | board/list | +| P-19 | Calendar week layout, per-day quick-add + overflow | **ADAPT** | CalendarView | +| P-20 | Two-pane notifications inbox, split mention badge | **ADAPT** | NotificationBell | +| P-21 | Surface human task IDs + copy-link | **ADOPT** | cards + TaskPanel | +| P-22 | Timeline zoom presets + edge-drag dates + hover-to-date | **ADAPT** | TimelineView (keep D-PM-12) | +| P-23 | Sprints reference design (join + snapshot-on-close + carry-forward) | **BANK** | future pm_sprints | +| P-24 | Webhook-out checklist (sign, log, retry, auto-disable, SSRF pin) | **BANK** | future workflows node | +| P-25 | Email digest outbox + sweep | **BANK** | when PM emails | +| P-26 | Export job pattern (token, presigned, expiry sweep) | **ADOPT (small)** | filtered-list CSV | +| P-27 | Delta-sync feed + satellite `updated_at` bump | **ADAPT (agents/mobile)** | list variant | +| P-28 | `is_epic` flag; project `timezone`; per-user view state; session `user_id` denorm | **ADOPT piecemeal** | small columns | +| P-29 | Public boards | **OWNER CALL (default no)** | §6 Q1 | +| P-30 | Pages/wiki | **REFUSE; ownership gap recorded** | §6 Q2 | +| P-31 | Modules; estimate systems; collab stack; pervasive soft-delete; stickies; their importer | **REFUSE** | §5 | diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index c451bcf6..1acaa2e2 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -2051,6 +2051,59 @@ hermetic suite can hold it. backslash escape rather than doing a substring match — a mirror that treated the pattern as a literal would have agreed with both the escaped and the unescaped implementation, and the whole defect would have been invisible to the suite that exists to catch it. + +### 11.19 Plane research — the beyond-parity queue (research 2026-08-09) + +*"I want you to learn and study this project as well and add it as another reference in +addition to Paca … come back with findings about what we can actually lift from it to make +our system fully featured and better, both in terms of backend as well as UI/UX."* + +Second reference studied: `makeplane/plane` v1.4.1. Full findings, evidence, and the +consolidated verdict table live in **`specs/plane_pm_research_2026-08.md`** (reference-only, +owns no work — same posture as the Paca doc). ⚠️ **Plane is AGPL-3.0**: patterns and +interaction designs only, never code — categorically stricter than Paca's Apache-2.0, and +the research doc's license wall is binding on every ticket below. + +**What the research changed here:** + +1. **Twelve of our shipped decisions are now validated against a second production + codebase** (research doc §2): per-view ordering, the trigger-enforced tenant key, the + atomic counter, cycle guards (Plane has none), the single visibility predicate, + 404-never-403, validate-then-apply bulk, page-batched aggregates, 422-over-fallback + (theirs arrived after two CVEs), statuses-as-data + priority-as-enum, single-writer + `completed_at`, agent-as-member. None of these should be re-litigated against a future + reference without reading that table first. + +2. **The beyond-parity ticket queue.** §11.2's ClickUp-parity backlog is CLOSED; the next + backlog is Plane-informed, tabled as P-1…P-31 in the research doc §8. The high-value + head of the queue, in recommended build order: + - **Intake/triage** (P-1) — wrapper row + `triage` status category excluded from default + lists + accept-in-place; the front door §6.5's email capture and agent-created tasks + have been missing. Pairs with `/workflows` for routing (D6: states in PM, automation + in the engine). + - **Watchers + mention diffing** (P-2) — `pm_task_watchers`, auto-subscribe on touch, + edits notify only *new* mentions. + - **Archive guard** (P-3, one predicate, do immediately) — refuse manual archive unless + the status category is done/cancelled; an archived open task silently exits every + default list. + - **Spreadsheet layout + kanban sub-grouping + display-properties contract + group-context + quick-add** (P-10…P-13) — the four UI gaps with the highest daily-use value. + - **Auto-archive policy** (P-4) — `archive_in`/`close_in` on root projects; sweeper is a + `/workflows` scheduled workflow, never a PM cron. + - Activity meta id+label rule and description-edit coalescing (P-5); semantic sort ranks + + deterministic tiebreaker (P-6); picker exclusions in search (P-7); human task IDs + surfaced with copy-link (P-21). + +3. **Two owner questions minted, deliberately undecided** (research doc §6): **Q1** public + read-only boards (would be the first anonymous tenant-data *read* route — default NO; + full risk analysis recorded), **Q2** who owns free-form project docs (PM assigns to + Notes; Notes declines; the gap is now recorded instead of silently unowned). + +4. **A non-goal reversed in part**: §5 refuses "a docs surface" and "sprints" — both stand, + but the sprints refusal now carries Plane's reference design (join-table membership, + snapshot-on-close, carry-forward — research doc §3.7) so the eventual build starts from + a settled shape rather than a blank page. + ## Board record (2026-08-09) — moved from work_plan.md §2 > Moved here in the 2026-08-09 consolidation (work_plan.md D18): board rows now diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index 7777c40f..b1a2bae4 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -183,7 +183,7 @@ owning specs are the archive; this file owns ordering, gates and states only. | WS-21 | **Calendar F2/F3** | 🟡 partial | `calendar_focus_os.md` §9 (+§5) + `calendar_timeboxing.md` §13 · board record 2026-08-09 | P3 roll-over + ideal-week + packer-breaks all shipped (struck from scope 2026-08-03). `gtd_time_blocks` is **four slices S1–S4** — the "one non-breaking PR" claim was false (17 TS files + 3 gateway modules + skill + agent). Focus Shield is AGENT-SAFE (needs a design, not a credential). Owns Horizons (§4) — DO-NOT-DISPATCH, no acceptance. 🔴 external-sync OAuth credentials (§6) · shared nudge-send gate (§6). Never `pytest tests/unit -k calendar` (collection hangs). (2026-08-03) | | WS-22 | **draw.io** | 🟡 owner | `drawio_integration.md` | All 13 tickets open, nothing built; best acceptance structure in the corpus; needs an owner and re-verified anchors (~6 weeks stale). ST-DRW-02 is a decision gate. | | WS-26 | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | ✅ a–g · D5 PR open | `specs/crm_app.md` · board record 2026-08-09 | a + b + c + d (read · email · write) **merged + deployed** (d-write log-verified via deploy `31217978773`, 2026-08-08); f + g **merged to main** (#391, #397 — the old "on branch, NOT run against prod" wording is struck; f's stage repair still needs its 🔴 `?apply=true` run, §6 WS-26 (d)). **D5 d-autolead BUILT, PR #403 OPEN** — owner: merge, then 🔴 `CRM_AUTO_LEAD` flip (§6 WS-26 (b); clamp-anchor design, never reset-to-now). Zoho sync loop **ENABLED by the owner 2026-08-06** (§6 WS-26 (a)) — every "ships OFF / never run" sentence about it is struck. Next: **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views — spec-thin, audit-narrow first · **e** cutover + retirement 🔴 (§6 WS-26 (c)). ⚠️ D15 coda: built single-Zoho-tenant by design; per-org credentials (migration 158) + per-org sync flags arrive with MT-1/MT-2, and D-CRM-3's org-wide read becomes org-scoped **by RLS**, not by hand-written predicates. (2026-08-08) | -| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–n merged · **o–t on PR #399** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07) | +| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–n merged · **o–t on PR #399** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted (head: intake/triage · watchers+mention-diff · archive guard · spreadsheet view · kanban swimlanes · display-properties · group quick-add), 2 owner questions (public boards — default NO · docs ownership gap). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07) | | WS-28 | **People Center — directory, org chart, assignment seam** *(minted 2026-08-06)* | ✅ a+b+b-write | `specs/people_center_app.md` · board record 2026-08-09 | a (key shape, mig 148 + quarantine table) · b (directory + person page, mig 149, five-place registration) · b-write (create/edit UI restored; found three ways mig 148 had broken the write routes) — built 2026-08-06/07; **closes WS-13's directory item**. 🟢 c org chart · d capability search (**ranking EVAL-LOCKED**) · e Projects seams; 🔴 f seats/roles writes (§6 WS-24 (d) analogue). ⚠️ `schema.generated.sql` regeneration is **due**: stale since ~migration 113, and 148 reached prod ~2026-08-07 (after the #384 cast fix). (2026-08-07) | --- From ae0a156db27a88dbdc5950acabb3dcf1fae01f52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 19:41:29 +0000 Subject: [PATCH 21/22] docs(D-PM-13, D-PM-14): the two Plane-research owner questions, answered same day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner answered both questions §11.19 minted, hours after they were asked. D-PM-13 — project docs live in the KNOWLEDGE BASE; PM links, never owns. Verbatim: "we have separately a knowledge base, so somehow the PM tool has to fit in with the knowledge base ... everybody who creates a knowledge base will own it, and if it's shared with multiple people or shared across the team, then depending on the user access, they have access to the knowledge base document." Recorded as an integration contract with four clauses: (1) PM never grows a docs surface — Plane's Pages stays refused permanently, not provisionally; (2) the KB access model is creator-owns + share-to-people-or-team, which is grant-vocabulary shaped — the KB should reuse email|group:|org (D12) rather than mint a second vocabulary; (3) two keys, never one — linking a doc to a task widens neither audience, R5 on both sides (the S2-8 two-door lesson, applied before the door exists this time); (4) the PM-side shape is a reference row resolved against the KB's own grants at read time, never a snapshot, which would silently fork access. Blocks nothing in the beyond-parity queue; exists so no ticket accidentally builds doc storage inside PM. D-PM-14 — public read-only boards DEFERRED. Verbatim: "For now, let's leave out public read-only boards. We will revisit it when needed." The research doc's §6 Q1 analysis (anchor capability URL, separate route module, no roster endpoint, per-board kill switch, the pre-SET-LOCAL RLS bypass point) is kept as the starting point for that revisit. Gateway posture unchanged: zero anonymous tenant-data read routes. Propagated: plane_pm_research_2026-08.md §6 + §8 (P-29/P-30), spec §11.19, work_plan WS-27 row, HANDOVER §1. Docs only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/HANDOVER.md | 7 ++- .../specs/plane_pm_research_2026-08.md | 26 ++++++++--- .../specs/project_management_app.md | 45 +++++++++++++++++-- ai-company-brain/work_plan.md | 2 +- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/ai-company-brain/HANDOVER.md b/ai-company-brain/HANDOVER.md index 86e49691..4f7a70af 100644 --- a/ai-company-brain/HANDOVER.md +++ b/ai-company-brain/HANDOVER.md @@ -63,8 +63,11 @@ dependencies, and a ⌘K search palette. `specs/plane_pm_research_2026-08.md`. ⚠️ AGPL-3.0 — patterns only, NEVER code (stricter than Paca's Apache-2.0). The beyond-parity ticket queue (P-1…P-31) is in that doc §8 and spec §11.19; the head of the queue is intake/triage, watchers, the archive guard, and the -spreadsheet view. The clone at `/workspace/makeplane/plane` is ephemeral to that sandbox — -re-clone shallow if you need to re-verify a citation. +spreadsheet view. Its two owner questions are ANSWERED: **D-PM-13** — project docs live in +the separate knowledge base (creator-owned, shared by grant; PM links with two-key access, +never owns or snapshots docs); **D-PM-14** — public read-only boards deferred. The clone at +`/workspace/makeplane/plane` is ephemeral to that sandbox — re-clone shallow if you need to +re-verify a citation. ### ⚠️ 1.1 The first thing to do, before any ticket diff --git a/ai-company-brain/specs/plane_pm_research_2026-08.md b/ai-company-brain/specs/plane_pm_research_2026-08.md index 4591be96..6b2d95c1 100644 --- a/ai-company-brain/specs/plane_pm_research_2026-08.md +++ b/ai-company-brain/specs/plane_pm_research_2026-08.md @@ -289,7 +289,11 @@ Verdicts here anneal into the UI work queue; each is an interaction spec, not a - **Stickies**: personal scratch notes are out of Projects scope; a project-less task already covers it. -## 6. Two questions this research raises for the owner (not decided here) +## 6. Two questions this research raised — ⚠️ BOTH ANSWERED 2026-08-09 (same day) + +> Answers recorded as **D-PM-13** (docs → knowledge base; PM links, never owns; two-key +> access) and **D-PM-14** (public boards deferred) in `project_management_app.md` §8. +> The analyses below are kept as the record each answer was given against. **Q1 — Public read-only boards.** Plane publishes any container under a capability URL (`anchor = uuid4().hex`, per-board kill switch, physically separate view tree + @@ -302,14 +306,22 @@ anchor→org **before** `SET LOCAL app.tenant_id` — one deliberate, auditable ever built: dedicated `routes/pm_public/` module with its own read-only models (never a flag on member endpoints), no member-roster endpoint (Plane exposes member names/avatars to anyone with the anchor — refuse that), per-board disable, rate limits, and a -leak-audit entry. The honest alternative is invite-as-restricted-guest. **Owner call; -default is NOT to build it.** +leak-audit entry. The honest alternative is invite-as-restricted-guest. +**ANSWERED — D-PM-14: deferred.** *"For now, let's leave out public read-only boards. We +will revisit it when needed."* This paragraph is the starting point for that revisit. **Q2 — Who owns project docs?** Plane's Pages (wiki with hierarchy, project attachment, versions, an embed/backlink log) is their second-biggest surface. Our PM spec assigns docs to Notes; `note_taker_app.md` §1.2 declares itself *not* a general document editor. -So **nobody currently owns free-form project documentation** — the gap is real and now -recorded in both specs' non-goals rather than silently unowned. +So nobody owned free-form project documentation — until this question was put to the +owner. **ANSWERED — D-PM-13:** there is a separate **knowledge base**; PM *fits in with* +it rather than owning docs. KB documents are creator-owned, shared to people or a team, +and visibility follows the share — grant-vocabulary shaped, so the KB should reuse +`email | group: | org` rather than mint a second vocabulary. PM's integration is a +reference row (task/project → doc), two-key access (the link never widens the doc's +audience, nor the doc the task's), R5 on both sides. Plane's Pages model remains useful +purely as the checklist of what the *KB* itself will eventually want: hierarchy, +project attachment, versions, an embed/backlink log. A third, smaller: Plane's `guest_view_all_features=false` mode (guests see only tasks they created) suggests a **restricted grant level** for contractors/clients — worth @@ -361,6 +373,6 @@ role. | P-26 | Export job pattern (token, presigned, expiry sweep) | **ADOPT (small)** | filtered-list CSV | | P-27 | Delta-sync feed + satellite `updated_at` bump | **ADAPT (agents/mobile)** | list variant | | P-28 | `is_epic` flag; project `timezone`; per-user view state; session `user_id` denorm | **ADOPT piecemeal** | small columns | -| P-29 | Public boards | **OWNER CALL (default no)** | §6 Q1 | -| P-30 | Pages/wiki | **REFUSE; ownership gap recorded** | §6 Q2 | +| P-29 | Public boards | **DEFERRED (D-PM-14, owner 2026-08-09)** | §6 Q1 | +| P-30 | Pages/wiki | **REFUSE — docs live in the knowledge base (D-PM-13)** | §6 Q2 | | P-31 | Modules; estimate systems; collab stack; pervasive soft-delete; stickies; their importer | **REFUSE** | §5 | diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index 1acaa2e2..1e30cb65 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -769,6 +769,43 @@ do** is reschedule for you, and if that turns out to be the thing actually wante still reachable — as an opt-in per project, with the cascade bounded and previewed before it writes, which is a better version of (b) than the one that would have shipped today. +**D-PM-13 — Project docs live in the KNOWLEDGE BASE; PM links to them, never owns them.** +`DECISION (owner-answered 2026-08-09).` The Plane research (§11.19, +`plane_pm_research_2026-08.md` §6 Q2) surfaced that free-form project documentation was +owned by nobody: this spec assigned it to Notes, and `note_taker_app.md` §1.2 declines it. +The owner's answer, verbatim: *"we have separately a knowledge base, so somehow the PM tool +has to fit in with the knowledge base and be able to do that. Now everybody who creates a +knowledge base will own it, and if it's shared with multiple people or shared across the +team, then depending on the user access, they have access to the knowledge base document."* + +What that binds, stated as the integration contract: + +1. **PM never grows a docs surface.** Plane's Pages stays refused (P-30); the §5 non-goal + is now permanent, not provisional. A "project doc" is a knowledge-base document that a + project or task **links to**. +2. **The KB's access model is: creator owns; shared to people or a team; visibility follows + the share.** That is grant-vocabulary shaped — the same `email | group: | org` + subjects `pm_project_grants` already uses (D12) are the natural encoding of "shared with + multiple people or across the team", and the KB should reuse that vocabulary rather than + mint a second one. +3. **Two keys, never one.** Linking a KB document to a task does NOT widen the document's + audience: a viewer sees the link's title/existence only if they satisfy the *document's* + grants, independently of satisfying the task's. The converse also holds — a doc reader + doesn't gain the task. R5 applies on both sides (a non-granted viewer gets 404, never a + locked-item stub). This is the same two-door lesson S2-8 taught about assignees. +4. **The PM-side shape, when the KB exists as a store:** a `pm_task_links`-style reference + row (task/project → KB doc id) rendered beside attachments in the panel, with the KB's + own grant check resolving at read time — never a copied snapshot of the doc, which would + silently fork access. Until the KB store lands, this decision blocks nothing in the + beyond-parity queue; it exists so no ticket accidentally builds doc storage inside PM. + +**D-PM-14 — Public read-only boards: DEFERRED.** +`DECISION (owner-answered 2026-08-09).` *"For now, let's leave out public read-only boards. +We will revisit it when needed."* Not built, not scheduled. The risk analysis to start from +when revisited is `plane_pm_research_2026-08.md` §6 Q1 — the anchor-capability-URL shape, a +physically separate route module with read-only models, no member-roster endpoint, per-board +kill switch, and the RLS-bypass point that must be resolved before `SET LOCAL app.tenant_id`. +Until then the gateway's posture is unchanged: no anonymous tenant-data read routes exist. --- @@ -2094,10 +2131,10 @@ the research doc's license wall is binding on every ticket below. + deterministic tiebreaker (P-6); picker exclusions in search (P-7); human task IDs surfaced with copy-link (P-21). -3. **Two owner questions minted, deliberately undecided** (research doc §6): **Q1** public - read-only boards (would be the first anonymous tenant-data *read* route — default NO; - full risk analysis recorded), **Q2** who owns free-form project docs (PM assigns to - Notes; Notes declines; the gap is now recorded instead of silently unowned). +3. **Two owner questions minted — and answered the same day** (research doc §6): **Q1** + public read-only boards → **deferred, D-PM-14** ("revisit when needed"); **Q2** who owns + free-form project docs → **the knowledge base, D-PM-13** — PM links to creator-owned, + grant-shared KB documents and never grows a docs surface of its own. 4. **A non-goal reversed in part**: §5 refuses "a docs surface" and "sprints" — both stand, but the sprints refusal now carries Plane's reference design (join-table membership, diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index b1a2bae4..636c1745 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -183,7 +183,7 @@ owning specs are the archive; this file owns ordering, gates and states only. | WS-21 | **Calendar F2/F3** | 🟡 partial | `calendar_focus_os.md` §9 (+§5) + `calendar_timeboxing.md` §13 · board record 2026-08-09 | P3 roll-over + ideal-week + packer-breaks all shipped (struck from scope 2026-08-03). `gtd_time_blocks` is **four slices S1–S4** — the "one non-breaking PR" claim was false (17 TS files + 3 gateway modules + skill + agent). Focus Shield is AGENT-SAFE (needs a design, not a credential). Owns Horizons (§4) — DO-NOT-DISPATCH, no acceptance. 🔴 external-sync OAuth credentials (§6) · shared nudge-send gate (§6). Never `pytest tests/unit -k calendar` (collection hangs). (2026-08-03) | | WS-22 | **draw.io** | 🟡 owner | `drawio_integration.md` | All 13 tickets open, nothing built; best acceptance structure in the corpus; needs an owner and re-verified anchors (~6 weeks stale). ST-DRW-02 is a decision gate. | | WS-26 | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | ✅ a–g · D5 PR open | `specs/crm_app.md` · board record 2026-08-09 | a + b + c + d (read · email · write) **merged + deployed** (d-write log-verified via deploy `31217978773`, 2026-08-08); f + g **merged to main** (#391, #397 — the old "on branch, NOT run against prod" wording is struck; f's stage repair still needs its 🔴 `?apply=true` run, §6 WS-26 (d)). **D5 d-autolead BUILT, PR #403 OPEN** — owner: merge, then 🔴 `CRM_AUTO_LEAD` flip (§6 WS-26 (b); clamp-anchor design, never reset-to-now). Zoho sync loop **ENABLED by the owner 2026-08-06** (§6 WS-26 (a)) — every "ships OFF / never run" sentence about it is struck. Next: **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views — spec-thin, audit-narrow first · **e** cutover + retirement 🔴 (§6 WS-26 (c)). ⚠️ D15 coda: built single-Zoho-tenant by design; per-org credentials (migration 158) + per-org sync flags arrive with MT-1/MT-2, and D-CRM-3's org-wide read becomes org-scoped **by RLS**, not by hand-written predicates. (2026-08-08) | -| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–n merged · **o–t on PR #399** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted (head: intake/triage · watchers+mention-diff · archive guard · spreadsheet view · kanban swimlanes · display-properties · group quick-add), 2 owner questions (public boards — default NO · docs ownership gap). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07) | +| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–n merged · **o–t on PR #399** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted (head: intake/triage · watchers+mention-diff · archive guard · spreadsheet view · kanban swimlanes · display-properties · group quick-add), 2 owner questions ANSWERED same day → **D-PM-13** (project docs live in the knowledge base — creator-owned, grant-shared; PM links, never owns) · **D-PM-14** (public boards deferred). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07) | | WS-28 | **People Center — directory, org chart, assignment seam** *(minted 2026-08-06)* | ✅ a+b+b-write | `specs/people_center_app.md` · board record 2026-08-09 | a (key shape, mig 148 + quarantine table) · b (directory + person page, mig 149, five-place registration) · b-write (create/edit UI restored; found three ways mig 148 had broken the write routes) — built 2026-08-06/07; **closes WS-13's directory item**. 🟢 c org chart · d capability search (**ranking EVAL-LOCKED**) · e Projects seams; 🔴 f seats/roles writes (§6 WS-24 (d) analogue). ⚠️ `schema.generated.sql` regeneration is **due**: stale since ~migration 113, and 148 reached prod ~2026-08-07 (after the #384 cast fix). (2026-08-07) | --- From d19124d1148382889f2dd41fdf20896dfc331e3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 20:09:07 +0000 Subject: [PATCH 22/22] =?UTF-8?q?docs(WS-27u=E2=80=93z):=20the=20beyond-pa?= =?UTF-8?q?rity=20queue=20becomes=20dispatchable=20tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner asked whether the Projects app is completely spec'd. It was not, in one specific way: the Plane research produced verdicts (P-1…P-31) but not tickets — shapes without done-when criteria, which is the difference between "annealed" and "an agent can pick up the next ticket without asking". Spec §9.1 now carries six tickets in build order, each with numbered done-when in the house style, each tracing to its P-numbers, all bound by the research doc's AGPL wall and by R1 (numbers at build time): - WS-27u intake/triage — pm_intake wrapper + triage category + ONE default-list exclusion predicate beside the visibility clause, parameter-coverage extended so no surface drops it; accept flips in place, never copies. - WS-27v watchers + mention discipline — auto-subscribe on touch; edits notify only NEW mentions (proven by an edit-twice hermetic test); audience still gated by resolve_visibility_for, not membership. - WS-27w read-path/history hardening basket — archive guard (422 on open), id+label activity meta (structurally enforced), description-edit coalescing, category-ranked sorts + (created_at,id) tiebreaker asserted structurally, picker exclusions, human IDs + copy-link. - WS-27x spreadsheet layout + shown-fields contract — one ticket because the column set IS the contract; taskCard.ts stays the fact layer. - WS-27y board upgrades — swimlanes, group-context quick-add (prefill is the point), drop-refusal overlays with the reason, keyboard cursor. - WS-27z lifecycle policy — archive/close months + project timezone; sweeper is a /workflows scheduled workflow per D6, default off, triage exempt, depends on w's archive guard. Flagged 🟡, not AGENT-SAFE: it writes real data on a schedule. Plus the deferred small basket (P-14/15/16/19/26/27/28) listed with pull triggers, and the banked items (sprints, webhook-out, digests) with theirs. work_plan WS-27 row, HANDOVER §1, and the research doc's status line now point at §9.1 instead of describing an unminted queue. Docs only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/HANDOVER.md | 5 +- .../specs/plane_pm_research_2026-08.md | 4 +- .../specs/project_management_app.md | 100 ++++++++++++++++++ ai-company-brain/work_plan.md | 2 +- 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/ai-company-brain/HANDOVER.md b/ai-company-brain/HANDOVER.md index 4f7a70af..f27ac5e9 100644 --- a/ai-company-brain/HANDOVER.md +++ b/ai-company-brain/HANDOVER.md @@ -62,8 +62,9 @@ dependencies, and a ⌘K search palette. **Plane research (2026-08-09)** — second PM reference beside Paca: `specs/plane_pm_research_2026-08.md`. ⚠️ AGPL-3.0 — patterns only, NEVER code (stricter than Paca's Apache-2.0). The beyond-parity ticket queue (P-1…P-31) is in that doc §8 and -spec §11.19; the head of the queue is intake/triage, watchers, the archive guard, and the -spreadsheet view. Its two owner questions are ANSWERED: **D-PM-13** — project docs live in +spec §11.19 — and it is **minted as dispatchable tickets WS-27u–z in spec §9.1**, with +done-when criteria, in build order: u intake/triage, v watchers, w read-path hardening, +x spreadsheet + shown-fields, y board upgrades, z lifecycle policy (default off). Its two owner questions are ANSWERED: **D-PM-13** — project docs live in the separate knowledge base (creator-owned, shared by grant; PM links with two-key access, never owns or snapshots docs); **D-PM-14** — public read-only boards deferred. The clone at `/workspace/makeplane/plane` is ephemeral to that sandbox — re-clone shallow if you need to diff --git a/ai-company-brain/specs/plane_pm_research_2026-08.md b/ai-company-brain/specs/plane_pm_research_2026-08.md index 6b2d95c1..69c08025 100644 --- a/ai-company-brain/specs/plane_pm_research_2026-08.md +++ b/ai-company-brain/specs/plane_pm_research_2026-08.md @@ -3,8 +3,8 @@ > **Product:** CommandCenter · **Concern:** second research appendix for the native > project-management app (WS-27), beside `paca_pm_research_2026-08.md` · **Created:** > 2026-08-09 · **Status:** 🟢 research complete — **reference-only, owns no work and no -> status**; adaptation verdicts are annealed into `specs/project_management_app.md` §11.19, -> which is the owning spec · **Owner:** vjvarada +> status**; adaptation verdicts are annealed into `specs/project_management_app.md` §11.19 and +> **minted as tickets WS-27u–z in its §9.1**, which is the owning spec · **Owner:** vjvarada > > **Research provenance (2026-08-09):** > - `makeplane/plane` @ `31853ab` (v1.4.1), shallow clone read at `/workspace/makeplane/plane` diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index 1e30cb65..08fe58e0 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -1064,6 +1064,106 @@ decision, and none of them is what was asked for. --- +### 9.1 The beyond-parity queue (minted 2026-08-09 from the Plane research, §11.19) + +Six tickets, in recommended build order. Each verdict traces to +`plane_pm_research_2026-08.md` (P-numbers); ⚠️ **the AGPL wall in that doc's header binds +every one of these** — shapes re-derived in our idiom, never translated. All of them inherit +the standing protocol: hermetic tests against the fake, mutation-tested guards, a live +Postgres run, and R1 (migration numbers resolved at build time — every number below is a +description, not an assignment). + +**WS-27u — intake/triage: the front door.** 🟢 AGENT-SAFE *(P-1)*. +A captured task is real from birth, parked out of sight until a human rules on it. +Done when: (1) a migration adds a `pm_intake` join table (`task_id` unique, `status ∈ +pending|accepted|declined|duplicate|snoozed`, `snoozed_until`, `duplicate_of_task_id`, +`source`, `source_ref`, `organization_id` per D-MT-3) and a `triage` value in the +status-category vocabulary; (2) the **default list exclusion is one predicate in +`core.py`** beside the visibility clause — tasks whose status category is `triage` appear +in no board/list/calendar/timeline/search surface unless `include_triage` is passed, and +the §11.16 parameter-coverage test is extended so no surface can drop it silently; +(3) `POST /projects/intake` creates task+wrapper in one transaction; accept flips status +in place (never copies), decline archives with the wrapper as provenance, duplicate sets +`duplicate_of_task_id` and archives, snooze hides from the queue until `snoozed_until`; +(4) all four actions write `pm_activities` rows and the wrapper survives them — provenance +is permanent; (5) a triage rail in the UI lists pending items with the four actions; +(6) visibility: the intake queue is scoped by the same project grants as the tasks it +wraps — R5 applies. **Not in scope:** routing rules (auto-accept, agent screening) — +those are `/workflows` nodes per D6, added when email capture (§6.5) lands. + +**WS-27v — watchers, and mentions that behave.** 🟢 AGENT-SAFE *(P-2, P-20 part)*. +Done when: (1) migration adds `pm_task_watchers(task_id, watcher, organization_id)`, +unique per pair; (2) commenting, editing, assigning, or being mentioned auto-subscribes +(idempotent), and explicit watch/unwatch endpoints exist; (3) the notification audience +becomes watchers ∪ assignees, still filtered by the recipient's actual visibility +(`resolve_visibility_for` stays the gate — Plane's membership-only check is the +counterexample, not the model); (4) **mention diffing**: editing a comment or description +notifies only *newly added* mentions — proven by a hermetic test that edits a comment +twice; (5) the actor of a change is never notified of it (existing rule, re-asserted over +the new audience); (6) the unread endpoint returns `{total, mentions}` separately and the +bell shows the mention count distinctly. **Not in scope:** notification snooze/archive. + +**WS-27w — read-path and history hardening.** 🟢 AGENT-SAFE *(P-3, P-5, P-6, P-7, P-21)*. +A basket of small corrections, each independently shippable: +(1) **archive guard** — archiving a task whose status category is not done/cancelled is +422, with the category named in the message; (2) **activity meta rule** — `field_change` +entries for FK-valued fields carry `{field, old_id, new_id, old_label, new_label}`, and a +structural test over `record_activity` call sites enforces it; (3) **description-edit +coalescing** — a same-actor consecutive description/comment-body edit updates the prior +activity row's timestamp instead of appending; (4) **semantic sorts** — sorting by status +orders by category rank then position, never alphabetically; every entry in `TASK_SORTS` +ends with a deterministic `(created_at, id)` tiebreaker, asserted structurally; (5) +**picker exclusions** — search accepts `exclude_relatives_of=` (self, ancestors, +descendants, already-related both directions) so pickers cannot offer what the write will +422; write-time guards stay; (6) **human task IDs** — the per-root number every task +already has renders on cards and panel with a copy-deep-link affordance. + +**WS-27x — the spreadsheet layout, and the shown-fields contract.** 🟢 AGENT-SAFE +*(P-10, P-12)*. Two pieces, one ticket, because the column set IS the contract. +Done when: (1) a per-view `shown_fields` list joins the saved-view config (`toConfig`/ +`fromConfig` round trip extended, tested); (2) every chip `TaskMeta` renders gates on it — +`taskCard.ts` stays the single fact-derivation layer, this is the visibility layer on top; +(3) a Table layout renders one row per task with columns = shown fields, inline editors +per cell driving the existing `PATCH` path (status, assignee, dates, importance, custom +fields), per-column header sort mapping to existing `TASK_SORTS`, sub-tasks expanding +indented in-table; (4) a quick-add row sits at the bottom (shares WS-27y's machinery); +(5) keyboard: arrows move the cell cursor, Enter edits, Esc cancels; (6) DESIGN_SYSTEM +throughout — no raw colours, `Icon`/`Button`/`Input` primitives, theme suite green. + +**WS-27y — board and list interaction upgrades.** 🟢 AGENT-SAFE *(P-11, P-13, P-17, P-18)*. +Done when: (1) **sub-grouping** — board accepts a second grouping axis rendered as +swimlanes (group columns × sub-group rows), per-lane collapse persisted with the view, +empty lanes hidden unless asked; (2) **group-context quick-add** — every list group, +board column/lane, and calendar day offers an inline title-only add **pre-filled with +that group's value** (status, assignee, date…), Enter submits and resets for the next; +(3) **drop feedback** — dragging where a drop is disallowed overlays the target with the +*reason*; after any drop or quick-add the moved card scrolls into view and flashes; +(4) **keyboard cursor** — ArrowUp/Down moves an active-row cursor, Shift+Arrow extends +the existing selection from it, Enter opens the panel; feeds `BulkBar` unchanged. + +**WS-27z — lifecycle policy: auto-archive and auto-close.** 🟡 *(P-4; the sweeper touches +real data on a schedule — enable per project, default off)*. +Done when: (1) migration adds `archive_after_months` and `close_after_months` (nullable +INT, NULL=off) to root `pm_projects`, plus a `timezone` column (P-28) so "a month +untouched" has a defensible midnight; (2) the sweeper is a **`/workflows` scheduled +workflow** (D6 — never a PM-app cron) that archives closed-category tasks untouched +beyond the window and closes stale open ones to the project's default closing status; +(3) every automated change writes an activity row flagged `automation: true` and renders +distinctly in the timeline; (4) tasks in `triage` (WS-27u) are exempt; (5) the manual +archive guard (WS-27w item 1) ships first — this ticket depends on it. + +**Deferred small basket** *(no ticket yet — pull individually when adjacent code is +touched)*: peek size escalation + Esc-returns-focus (P-14), Save/**Update view** dirty +affordances (P-15), palette action registry + go-sequences (P-16), calendar week layout + +per-day quick-add/overflow (P-19), filtered-list CSV export (P-26), delta-sync feed + +satellite `updated_at` bump (P-27), `is_epic` flag + per-user view state + session +`user_id` denorm (P-28 rest). Banked for their trigger events: sprints (P-23, when +sprints are wanted), webhook-out checklist (P-24, when `/workflows` grows the node), +email digest outbox (P-25, when PM emails). Owner-decided: docs = knowledge base +(D-PM-13); public boards deferred (D-PM-14). + +--- + ## 10. Verification ⚠️ Never `uv run pytest tests/unit/` bare — whole-directory collection hangs on the diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index 636c1745..ba92e598 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -183,7 +183,7 @@ owning specs are the archive; this file owns ordering, gates and states only. | WS-21 | **Calendar F2/F3** | 🟡 partial | `calendar_focus_os.md` §9 (+§5) + `calendar_timeboxing.md` §13 · board record 2026-08-09 | P3 roll-over + ideal-week + packer-breaks all shipped (struck from scope 2026-08-03). `gtd_time_blocks` is **four slices S1–S4** — the "one non-breaking PR" claim was false (17 TS files + 3 gateway modules + skill + agent). Focus Shield is AGENT-SAFE (needs a design, not a credential). Owns Horizons (§4) — DO-NOT-DISPATCH, no acceptance. 🔴 external-sync OAuth credentials (§6) · shared nudge-send gate (§6). Never `pytest tests/unit -k calendar` (collection hangs). (2026-08-03) | | WS-22 | **draw.io** | 🟡 owner | `drawio_integration.md` | All 13 tickets open, nothing built; best acceptance structure in the corpus; needs an owner and re-verified anchors (~6 weeks stale). ST-DRW-02 is a decision gate. | | WS-26 | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | ✅ a–g · D5 PR open | `specs/crm_app.md` · board record 2026-08-09 | a + b + c + d (read · email · write) **merged + deployed** (d-write log-verified via deploy `31217978773`, 2026-08-08); f + g **merged to main** (#391, #397 — the old "on branch, NOT run against prod" wording is struck; f's stage repair still needs its 🔴 `?apply=true` run, §6 WS-26 (d)). **D5 d-autolead BUILT, PR #403 OPEN** — owner: merge, then 🔴 `CRM_AUTO_LEAD` flip (§6 WS-26 (b); clamp-anchor design, never reset-to-now). Zoho sync loop **ENABLED by the owner 2026-08-06** (§6 WS-26 (a)) — every "ships OFF / never run" sentence about it is struck. Next: **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views — spec-thin, audit-narrow first · **e** cutover + retirement 🔴 (§6 WS-26 (c)). ⚠️ D15 coda: built single-Zoho-tenant by design; per-org credentials (migration 158) + per-org sync flags arrive with MT-1/MT-2, and D-CRM-3's org-wide read becomes org-scoped **by RLS**, not by hand-written predicates. (2026-08-08) | -| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–n merged · **o–t on PR #399** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted (head: intake/triage · watchers+mention-diff · archive guard · spreadsheet view · kanban swimlanes · display-properties · group quick-add), 2 owner questions ANSWERED same day → **D-PM-13** (project docs live in the knowledge base — creator-owned, grant-shared; PM links, never owns) · **D-PM-14** (public boards deferred). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07) | +| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–n merged · **o–t on PR #399** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted → **minted as dispatchable tickets WS-27u–z (spec §9.1)**: u intake/triage · v watchers+mention-diff · w read-path/history hardening · x spreadsheet+shown-fields · y board upgrades · z lifecycle policy (🟡 per-project, default off) + a deferred small basket, 2 owner questions ANSWERED same day → **D-PM-13** (project docs live in the knowledge base — creator-owned, grant-shared; PM links, never owns) · **D-PM-14** (public boards deferred). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07) | | WS-28 | **People Center — directory, org chart, assignment seam** *(minted 2026-08-06)* | ✅ a+b+b-write | `specs/people_center_app.md` · board record 2026-08-09 | a (key shape, mig 148 + quarantine table) · b (directory + person page, mig 149, five-place registration) · b-write (create/edit UI restored; found three ways mig 148 had broken the write routes) — built 2026-08-06/07; **closes WS-13's directory item**. 🟢 c org chart · d capability search (**ranking EVAL-LOCKED**) · e Projects seams; 🔴 f seats/roles writes (§6 WS-24 (d) analogue). ⚠️ `schema.generated.sql` regeneration is **due**: stale since ~migration 113, and 148 reached prod ~2026-08-07 (after the #384 cast fix). (2026-08-07) | ---