diff --git a/ai-company-brain/HANDOVER.md b/ai-company-brain/HANDOVER.md new file mode 100644 index 00000000..f27ac5e9 --- /dev/null +++ b/ai-company-brain/HANDOVER.md @@ -0,0 +1,348 @@ +# 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 + +**Tree clean, everything pushed.** Open PR **#399**. + +> ### ⚠️ 2026-08-09 — `main` moved, and this branch has been merged with it +> +> A parallel workstream landed the SaaS multi-tenancy programme (**PR #404**, MT-0a…MT-1i) +> while this branch was open. `origin/main` was merged in rather than left to conflict, and +> three things changed as a result. Read these before §1.1. +> +> **1. The migrations were renumbered.** `main` took 157/158/159, so this branch's became +> **160** (`projects_recurrence`), **161** (`projects_tenancy`) and **162** +> (`app_user_email_case`). Numbers in older commit messages and in `#399`'s body refer to the +> pre-merge names. House rule R1 still applies to you: resolve the next free number at build +> time, never from a document. +> +> **2. D-MT-2 is answered, and not by this branch.** `specs/saas_multitenancy.md` is canonical: +> **D15 — pooled, enforced by RLS** against an `app.tenant_id` GUC bound by +> `acb_common.db.tenant_session()`. `specs/multi_tenancy.md` here is now the *measured* record +> only and says so at the top. Anything in §3 below that waited on D-MT-2 is either superseded +> by MT-1b/MT-1c or needs re-reading against that spec first. +> +> **3. This branch found a defect in MT-1b, and gated it.** `gen_tenant_migration.py` scoped +> tables by column *name*, and `crm_contacts` / `crm_deals` / `crm_activities` have an +> `organization_id` that references `crm_organizations` — a customer company. Its phase 2 would +> have written a tenant id into that column and aborted **inside the maintenance window**. +> `discover_homonyms()` + `HOMONYM_BLOCKED` now refuse at generation time (exit 1), and +> `test_tenancy_boundary.py` asserts it in CI. ⚠️ **Those three tables consequently carry no +> tenant isolation at all** — that is an owner call (rename the CRM column), written up in +> `specs/multi_tenancy_leak_audit.md` §2.1. + +| 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. + +**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 — 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 +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:** + +- `infra/postgres/161_projects_tenancy.sql` — `organization_id NOT NULL` on all 17 `pm_*` + tables, plus a parent-consistency trigger. +- `infra/postgres/162_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 161 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. **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. +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~~ ✅ SUPERSEDED by MT-1b + MT-1c on `main` + +This ticket's recommendation on record was Postgres RLS, because it is the only option where +the *absence* of code is safe rather than a leak. **That is what D15 chose**, and `main` +already carries it: `infra/postgres/generated/04_policies.sql` (ENABLE + FORCE + USING + +WITH CHECK per table) and `acb_common.db.tenant_session()` for the binding half. + +**Do not build this.** Read `specs/saas_multitenancy.md` §1.3 and the H1→H8 runbook in +`saas_multitenancy_handover.md` instead. The one thing this branch adds on top is the homonym +gate described in §1 — the three CRM tables that RLS must NOT be pointed at yet. + +### 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 `161_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?**~~ ✅ **ANSWERED 2026-08-09 on `main` as D15: + pooled, enforced by RLS.** Nothing here blocks on it any more. +- ⚠️ **NEW — the CRM column name.** `crm_contacts` / `crm_deals` / `crm_activities` already use + `organization_id` for the *customer company*, so they cannot be tenant-scoped under that name + and are currently **outside RLS entirely**. Rename the CRM column + (`organization_id` → `crm_organization_id`, touching every CRM route and query), or give the + tenant key a different name on those three tables. Until then the hole is real and visible; + it is deliberately not filed as an exemption. See `multi_tenancy_leak_audit.md` §2.1. +- **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 161 + 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 162.) + +**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/deploy_delivery_path.md b/ai-company-brain/specs/deploy_delivery_path.md index 27e403ce..be31b4c5 100644 --- a/ai-company-brain/specs/deploy_delivery_path.md +++ b/ai-company-brain/specs/deploy_delivery_path.md @@ -132,6 +132,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 @@ -266,6 +282,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/ai-company-brain/specs/multi_tenancy.md b/ai-company-brain/specs/multi_tenancy.md new file mode 100644 index 00000000..62e8797e --- /dev/null +++ b/ai-company-brain/specs/multi_tenancy.md @@ -0,0 +1,255 @@ +# Multi-tenancy — isolating organizations in CommandCenter + +> ## ⚠️ SUPERSEDED FOR ARCHITECTURE (2026-08-09) — read `saas_multitenancy.md` first +> +> A parallel workstream landed the full design in **PR #404** while this branch was open, and +> it is canonical: **`ai-company-brain/specs/saas_multitenancy.md`** (architecture, §11 +> tickets), plus `saas_multitenancy_handover.md` (the H1→H8 runbook) and +> `saas_multitenancy_implementation.md` (shapes). Where the two disagree, that one wins. +> +> **What it answers that this document left open:** **D-MT-2 is decided — D15, pooled, enforced +> by row-level security** against an `app.tenant_id` GUC that `acb_common.db.tenant_session()` +> binds with `SET LOCAL` inside a transaction. §4's WS-29c row below (a flag-gated RLS +> experiment) is therefore struck: it is MT-1b + MT-1c on `main`, already built. +> +> **What this document is still good for**, and why it is kept rather than deleted: it is the +> *measured* record — the table counts, the two corrections marked ⚠️ below, and the reasoning +> behind the `pm_*` key that migration 161 actually carries. `multi_tenancy_leak_audit.md` +> beside it is likewise still live: its 14 findings are about paths a database predicate does +> not close, and RLS does not close them either. +> +> **Ticket-ID collision, stated so nobody reconciles it twice.** Both workstreams minted +> "WS-29". `work_plan.md`'s WS-29 row is the SaaS one. The tickets in §4 here (WS-29a…e) are +> this branch's, and only **a** and **b** were built; the rest are superseded by MT-0/MT-1. + +> **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 a real tenant key | **3** | +| Carrying none | **140** | +| `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)`. + +> ⚠️ **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 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 +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**, 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 162 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 162** (`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 + +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.**~~ — **SATISFIED 2026-08-08 by migration 161**, which keyed all seventeen tables. +Kept rather than deleted because the reasoning is what generalises, and because **one condition +replaced it: migration 161 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 +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.** + +**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 + +### D-MT-1 — Can one person belong to more than one organization? + +`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 + 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. + +**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. **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*? + +`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**~~ ✅ **BUILT** — migration 161. ⚠️ 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~~ ✅ **unblocked** — but apply 161 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. + +--- + +## 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/specs/multi_tenancy_leak_audit.md b/ai-company-brain/specs/multi_tenancy_leak_audit.md new file mode 100644 index 00000000..f4291644 --- /dev/null +++ b/ai-company-brain/specs/multi_tenancy_leak_audit.md @@ -0,0 +1,763 @@ +# 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/161_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 161 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. ✅ **FIXED same day.** `tests/unit/test_tenancy_boundary.py` matched 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. ✅ **FIXED same day.** `EXPECTED_SCOPED` asserted the three CRM tables were 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. + +### 2.1 ⚠️ It bit MT-1b's generated migration (found 2026-08-09, on the merge) + +Consequence 4 stopped being advice when `main` merged PR #404. `scripts/gen_tenant_migration.py` +emits its four phases for every discovered table not in `EXEMPT`, and the three CRM tables were +not in `EXEMPT` — because nothing in that generator, or in `tests/unit/test_tenant_coverage.py`, +ever looks at what an existing `organization_id` **references**. What it generated for them: + +```sql +-- phase 1 no-op: the column exists (pointing at crm_organizations) +ALTER TABLE crm_contacts ADD COLUMN IF NOT EXISTS organization_id UUID …; +-- phase 2 writes a TENANT id into the customer-company column +UPDATE crm_contacts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; +-- phase 3 a second, contradictory FK on one column +ALTER TABLE crm_contacts ADD CONSTRAINT crm_contacts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +``` + +**Reproduced against a live Postgres 16** with the full ladder applied — two contacts, one at a +company and one without, which is the ordinary shape of that table: + +``` +--- MT-1b phase 2, as the UNFIXED generator emitted it --- +ERROR: insert or update on table "crm_contacts" violates foreign key constraint + "crm_contacts_organization_id_fkey" +DETAIL: Key (organization_id)=(52eb2a2d-…-e40bbbc3e1ab) is not present in table + "crm_organizations". +``` + +Note what makes it invisible until then: on an **empty** `crm_contacts` the same two statements +return `UPDATE 0` and `ALTER TABLE` and look completely healthy. It needs one real row to fail, +which is exactly the condition the production database has and a test fixture does not. + +Phase 2 aborts on the existing `REFERENCES crm_organizations` FK; if it somehow did not, phase 3 +fails on every pre-existing value. Either way the failure lands **inside the maintenance +window, after phase 1 has already run** — which is the worst moment to discover it, and the +generator's own docstring is explicit that promoting these files is a hand act in a window. + +**Fixed on this branch, at generation time instead of apply time.** `discover_homonyms()` +derives the conflicting tables from the migrations; `HOMONYM_BLOCKED` is the human sign-off +carrying each reason; the generator **refuses to emit anything and exits 1** when the two +disagree, and lists the blocked tables in every generated file's header for whoever is holding +the psql prompt. `test_tenancy_boundary.py` asserts the same two invariants in CI, so a fourth +homonym added next month is a red build rather than a failed apply. + +**What is NOT fixed, and is an owner call.** Those three tables now carry **no tenant +isolation at all** — under D15's pooled RLS an unpoliced table is readable by every tenant, and +these hold customer CRM records. Blocking them makes the gap visible and stops it corrupting a +business column; it does not close it. Closing it is consequence 4 above, and it is a rename +touching every CRM route and query. Deliberately **not** filed in `EXEMPT`: exempt means "needs +no isolation", and saying that about customer contact data would turn a hole into a decision +nobody revisits. + +--- + +## 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 161 gives it one anyway +(`161_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/161_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/specs/plane_pm_research_2026-08.md b/ai-company-brain/specs/plane_pm_research_2026-08.md new file mode 100644 index 00000000..69c08025 --- /dev/null +++ b/ai-company-brain/specs/plane_pm_research_2026-08.md @@ -0,0 +1,378 @@ +# 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 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` +> (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 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 + +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. +**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 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 +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 | **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 2cfbbe7e..08fe58e0 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -693,6 +693,120 @@ eval lock, and the owner performs one confirmation step per import run. **Scope supersedes the earlier "pilot Space vs all Spaces" framing — scope is now a per-Space decision the plan step surfaces, so both a pilot and a full import are the same code path. +**D-PM-11 — The timeline shows a chosen SCOPE, not every task.** +`DECISION (agent-proposed 2026-08-08, owner delegated the choice back — "go ahead with the +decision that you think would be best to make the product as useful as possible").` +**ANSWERED: (b), with (c) underneath.** A Gantt of 400 rows is a +wall nobody reads, so something has to decide which tasks earn a bar. Three candidates, and +they are not equivalent: + +* **(a) A task TYPE, the Paca answer.** Paca's Timeline pre-filters to the `Epic` system type + and its view settings let you add others back. Clean, and it costs us a convention we do + not have: `pm_task_types` is per-root data with no reserved names, so "Epic" would either + become a seeded row every project inherits or a name-match, and a name-match is a rule that + silently stops working the day somebody renames a type. +* **(b) Hierarchy DEPTH.** Top-level tasks get bars; subtasks roll up into the parent's bar + and expand on click. Needs no new vocabulary — `parent_task_id` already says it — and it + matches how the tree is already drawn everywhere else in this app. +* **(c) Whatever the current filters select.** No new concept at all: the timeline is the + board's filters in a third shape, which is the rule §11.16 already holds for the calendar. + Honest, and it puts the wall back the moment somebody clears the filters. + +**Chosen: (b), with (c) underneath it** — depth decides the default and the filter bar +still narrows, so the two compose instead of competing. **Rejected:** (a) as the primary, +because inventing a reserved type name to make a chart legible is a data-model change in +service of a rendering problem, and D-PM-2 put types in the hands of each project on purpose. +**Cost:** a subtask's dates have to roll up into the parent's bar, which means the parent's +bar is sometimes derived rather than stored, and "why does this bar not match the dates I +typed" becomes a question the UI has to answer on the bar itself. + +**D-PM-12 — Does a dependency CONSTRAIN the schedule, or only describe it?** +`DECISION (owner-delegated 2026-08-08).` The owner was given the three options and their +costs, and answered *"go ahead with the decision that you think would be best to make the +product as useful as possible"* — so the agent's recommendation was taken as the decision +rather than the question being left open. **ANSWERED: (c) — constrain, but only warn.** +Recorded this way, and not as an agent proposal, because the delegation was explicit and +the reasoning below is what it was delegated on. + +This is the one that changes what the data means, which is why it is not agent-proposed. +Jira and ClickUp both offer to **push** a dependent task's dates when you move its blocker. +Adopting that turns `pm_task_links` from a description into a constraint: + +* **(a) Describe only.** Drawing an arrow records the dependency and moves nothing. This is + the straight extension of WS-27p, which states the position explicitly — *"blocked-ness is + DERIVED and SHOWN, never enforced"* — on the argument that 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. **Cost:** the chart will show arrows pointing + backwards in time, because nothing stops a blocker being due after the task it blocks. + Users read that as the feature being broken. +* **(b) Constrain, and auto-push.** Moving a blocker drags its dependents forward. What + people expect from Jira. **Cost, and it is not small:** one drag becomes an unbounded + cascade of writes across a project — every one of them a real `PATCH` with a + `field_change` activity and a revert (§3.8), so a single gesture can produce fifty timeline + rows and fifty notifications. It also directly contradicts WS-27p's stated position, so + taking it means striking that paragraph rather than quietly living beside it. +* **(c) Constrain, but only warn.** The arrow goes red and the panel says "this starts before + its blocker finishes"; nothing is written. Keeps WS-27p's position intact, kills the + backwards-arrow complaint, and adds no cascade. **Cost:** somebody still has to do the + rescheduling by hand, which is exactly the work (b) automates. + +**Chosen: (c).** It is the only one of the three that neither contradicts a decision already +made nor lets one gesture write fifty rows, and it can become (b) later behind an explicit +per-project setting — whereas (b) cannot become (c) without taking a behaviour away from +people who have started relying on it. **WS-27p's position therefore stands unamended:** +blocked-ness is derived and shown, never enforced, and a schedule conflict is now shown the +same way — a red arrow and a sentence, with nothing written. + +**What "useful as possible" actually argued for, since that was the brief.** The reflex +answer is (b), because auto-push is the feature Jira advertises. But the useful half of a +dependency is *knowing* — being told, at the moment you move something, that two tasks now +disagree. (b) delivers that and then also silently rewrites other people's dates, which is +where it stops being useful: the cascade lands in the timeline (§3.8) as fifty +`field_change` rows and fifty notifications with no single act to point at, and the first +time somebody's carefully-negotiated date moves without them touching it, they stop trusting +the dates. (c) keeps the information and drops the part that costs trust. **What it does not +do** is reschedule for you, and if that turns out to be the thing actually wanted, (b) is +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. + --- ## 9. Tickets @@ -888,6 +1002,166 @@ owner-scoped predicates and the `gtd_*` task tables are gone. See §7.5. parity sign-off, sync flips, consumer repoint, token revocation, constraint-8 amendment — each registered in `work_plan.md` §6). +**WS-27t — the timeline, and dependencies you can draw.** ✅ **BUILT 2026-08-08.** +Was 🟡 blocked on D-PM-12; the owner delegated the choice back on 2026-08-08 and it is +answered as **(c) constrain-and-warn**, with D-PM-11 as **(b) hierarchy depth**. Both are +recorded in §8 with the alternatives they beat. + +*Asked for directly, 2026-08-08:* **"a timeline view that can also make tasks and subtasks +dependent on each other, with wiring them to each other, similar to how it works on Jira and +ClickUp."** Two things, and the second is the one that matters — a Gantt chart with no +dependency gesture is decoration, which is precisely why Gantt was a non-goal until now. + +**The data is already built.** This is a rendering-and-gesture ticket, not a schema one: + +| Needed | Status | +|---|---| +| `start_date` (DATE) + `due_at` (timestamptz) on every task | ✅ migration 146, surfaced at WS-27q | +| `pm_task_links` with `blocks`, `CHECK(source <> target)` | ✅ WS-27a | +| Cycle refusal on `blocks` (`assert_no_block_cycle`, `MAX_DEPTH`-bounded) | ✅ WS-27p | +| Both-direction read with `direction` on each link | ✅ WS-27p `GET /tasks/{id}/relations` | +| Blocked-count and subtask progress per row, in one aggregate | ✅ WS-27s `attach_relation_counts` | +| Interval-overlap window query, timezone-safe | ✅ WS-27q `OVERLAPS` | +| Move a task's dates through the ordinary write path | ✅ WS-27q `rescheduleTo` → `PATCH /tasks/{id}` | + +**What is genuinely new is three things.** (1) Bar geometry on a continuous date axis instead +of a day grid. (2) `GET /projects/timeline`, or an argument for why the calendar endpoint +serves both — it very nearly does, and the honest difference is that a timeline wants the +LINKS for every row in the window, which is one more aggregate of exactly the shape +`attach_relation_counts` already is. (3) The arrow gesture, which is the only part with no +precedent anywhere in this tree. + +**Paca has the chart and not the wiring.** `apps/web/src/components/projects/interactions/ +roadmap-view.tsx` (438 lines, Apache-2.0) is a real Gantt and worth taking the geometry from: +a sticky 280px task column beside a scrolling canvas, `PX_PER_DAY = 28`, month header cells +computed by walking `Date(y, m+1, 1)`, a today line, a range auto-fitted to the data with +seven days of padding either side, single-date tasks drawn as a one-day bar, and — the rule +this app would have arrived at anyway — **an undated task listed on the left with no bar**, +which is the same honesty §11.16 enforces with its `undated` count. It draws **no dependency +arrows at all** (zero matches for arrow/svg/path/depend) and is **entirely read-only** (zero +for drag/resize). So Paca answers "how do I lay out bars"; for the wiring, Jira and ClickUp +are the reference and the interaction is ours to design. + +**Two decisions gate it.** **D-PM-11** — what earns a bar (agent-proposes hierarchy depth, +owner may overrule). **D-PM-12** — whether an arrow constrains the schedule or only describes +it (**owner-answer required**; the agent recommends *warn, do not push*, because auto-push +contradicts WS-27p's stated position and turns one drag into an unbounded cascade of real +`PATCH`es, each carrying a `field_change` activity and a notification). + +**Done when:** (1) a timeline view renders every task in a window as a bar from `start_date` +to `due_at`, with undated tasks listed and unbarred; (2) `blocks` links are drawn as arrows +between bars, in the direction WS-27p already stores; (3) dragging from one bar to another +creates a `blocks` link through the existing endpoint, and a drag that would close a cycle is +refused with `assert_no_block_cycle`'s existing message rather than a new one; (4) whatever +D-PM-12 decides is implemented and its rejected alternatives are recorded; (5) the board's +filters apply, and the parameter-coverage test §11.16 added is extended to the new endpoint +so a filter cannot be dropped silently; (6) the geometry is pure and tested — including +across at least three timezones, the WS-27q lesson. + +**Not in scope:** resizing a bar by dragging its edge (a second gesture with its own +half-day/rounding questions), critical-path computation, and baselines. Each is a separate +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 @@ -945,14 +1219,25 @@ 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** | -| 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** | - -**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 -Gantt. If any is wanted, it is a decision to record, not an omission to fix. +| 7 | ~~**Recurring tasks**~~ | — | **WS-27o ✅ BUILT 2026-08-07** | +| 8 | ~~**Dependency and subtask UI**~~ | — | **WS-27p ✅ BUILT 2026-08-07** | +| 9a | ~~**Calendar view**~~ | — | **WS-27q ✅ BUILT 2026-08-08** | +| 9b | **Timeline view** (Gantt bars on a date axis) | The calendar answers *what is due when*; it cannot answer *what runs alongside what*, which is the question a multi-month project asks | **WS-27t** | +| 10 | ~~**Global task search**~~ | — | **WS-27r ✅ BUILT 2026-08-08** | +| 11 | ~~**The card looks nothing like /tasks'**~~ | — | **WS-27s ✅ BUILT 2026-08-07** | +| 12 | **Dependencies cannot be drawn** | `blocks` exists and is cycle-guarded, but wiring one means a dropdown and a task number in a panel — Jira and ClickUp make it a drag between two bars | **WS-27t** | + +**Deliberately NOT on this list:** sprints (a stated non-goal, §1), and time tracking and +checklists (Paca moved both out of core into plugins — the growth path is subtraction). If +any is wanted, it is a decision to record, not an omission to fix. + +~~and Gantt~~ — **REVERSED 2026-08-08, owner-asked.** Kept struck rather than deleted +because the reversal is the interesting part: the original note treated Gantt as decoration, +which is true of the *chart* and false of the thing the owner actually asked for — a surface +where a dependency is DRAWN rather than typed. `pm_task_links` has been cycle-guarded since +WS-27p and reachable only through a dropdown and a task number; the chart is the gesture's +excuse to exist. Recorded as it should have been: a decision, in **D-PM-11** and +**D-PM-12**, with a ticket (**WS-27t**) that does not start until D-PM-12 is answered. ### 11.3 Sequencing, and the one dependency that matters @@ -1480,6 +1765,482 @@ matched first and the audience branch keys off `assignee AS who`, which only its 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 `160_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. + +### 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. + +### 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. + +### 11.16 WS-27q — the calendar (built 2026-08-08) + +**Backlog row 9 was named "Calendar / timeline view" and this built the calendar half only.** +Recorded here because closing the whole row was wrong: a month grid of day cells answers *what +is due when*, and a timeline of bars on a continuous axis answers *what runs alongside what*. +They are different questions, the second is the one a multi-month project asks, and the row is +now split — 9a closed, **9b open as WS-27t**. + +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. + +### 11.17 WS-27t — the timeline, and dependencies you can draw (built 2026-08-08) + +Asked for directly: *"a timeline view that can also make tasks and subtasks dependent on each +other, with wiring them to each other, similar to how it works on Jira and ClickUp."* Two +things, and the second is the one that matters — **a Gantt chart with no dependency gesture is +decoration**, which is exactly why Gantt was a non-goal until this was asked for. + +**Almost none of this was schema work.** Dates, links, the cycle guard, the both-direction +read, blocked counts and the interval-overlap window all shipped in WS-27a/p/q/s. The whole +ticket is one aggregate, some geometry, and a gesture. + +**The window is the resource, so there is no `/projects/timeline`.** `GET /projects/calendar` +grew `include_links`, and calendar and timeline are two renderings of one question — the same +rule §11.8 states for list and board, extended a third time. A second endpoint would be a +second filter surface to keep in step. + +**An arrow needs two bars, so an edge is returned only when BOTH ends are in the window.** +The edge to an off-window blocker is not lost, it is undrawable: `blocked_by_count` already +badges the visible bar, which is the honest rendering of *"something you cannot see is holding +this up"*. Only `blocks` is drawn — `relates_to` and `duplicates` have no direction that means +anything to a schedule (WS-27p's `DIRECTED_TYPES`), and an arrow would claim a sequence nobody +asserted. + +#### D-PM-11 — hierarchy depth decides what earns a bar + +Top-level tasks get rows; subtasks fold in and expand on a chevron. **A parent with no dates +of its own borrows its children's span**, marked `derived` and drawn dashed, because otherwise +the default view is blank for exactly the projects that use subtasks properly. **A subtask +whose parent is off-window is promoted to its own row** rather than hidden — hiding it is how +a filtered timeline silently drops work. + +Paca's Timeline pre-filters to a reserved `Epic` type instead. Rejected: `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 stops working the day somebody renames +a type. `parent_task_id` already means depth and cannot be renamed. + +#### D-PM-12 — an arrow WARNS; it never reschedules + +The owner was given the three options and delegated the choice back. Chosen: **constrain, but +only warn**. A `blocks` edge whose blocker's END falls after the blocked task's START is drawn +in the danger tone with a sentence that says *nothing has been rescheduled*. + +**Why not Jira's auto-push,** which was the reflex answer: the useful half of a dependency is +*knowing* — being told, the moment you move something, that two tasks now disagree. Auto-push +delivers that and then also silently rewrites other people's dates, which is where it stops +being useful. The cascade lands in the activity spine (§3.8) as dozens of `field_change` rows +and dozens of notifications with no single act to point at, and the first time somebody's +negotiated date moves without them touching it, they stop trusting the dates. It also +contradicts WS-27p's written position — blocked-ness is **derived and shown, never enforced** +— which now stands unamended. (b) remains reachable later as an opt-in per project, with the +cascade bounded and previewed before it writes; that is a better version of it than the one +that would have shipped today. + +Three sub-rules, each one a way the warning could have become noise: + +* **equal dates are not a conflict.** A blocker due the 10th and a task starting the 10th is + the normal way people schedule a handover. Flagging it fires on half a healthy plan, after + which nobody reads the warning at all. +* **a missing date on either end is not a conflict.** It is unknowable, and a warning that + fires on absent data teaches people it means nothing. +* **a finished blocker never conflicts.** WS-27p's rule applied to the warning exactly as it + applies to the badge. + +**One rule, two surfaces.** `conflicts()` is pure and lives in `lib/timeline.ts`; the timeline +colours its arrows with it and `RelationsBlock` writes its sentence with it — which is why +`GET /tasks/{id}/relations` grew `start_date` and `due_at`. Two implementations of *"does this +start before its blocker finishes"* would eventually disagree, and the one that got it wrong +would be the surface nobody was looking at. + +**The cycle check is NOT duplicated in the browser.** `canLink` refuses only self-links and +exact duplicates; `a→b` when `b→a` exists is allowed through so `assert_no_block_cycle` +refuses it with its own message. A second bounded graph walk in the client is the one that +drifts, and a drag creates a link through the same `POST /tasks/{id}/links` the panel's +dropdown uses — same guard, same activity, same permission. + +**Paca gave the layout and nothing else.** `roadmap-view.tsx` (438 lines, Apache-2.0): +sticky task column, fixed pixels-per-day, month cells walked with `Date(y, m+1, 1)`, a today +line, a data-fitted range with padding, an undated task listed and unbarred. It draws **no +dependency arrows** and is **entirely read-only**, so everything from the handle onwards is +ours. + +**Two rounding traps, and only one is catchable in CI's timezone.** `dayPx` rounds its +millisecond division because a range that straddles a DST transition is 23 or 25 hours across +it, and an unrounded quotient lands a fraction of a day off for every day after — permanently. +The behavioural test only fails in a zone that *has* daylight saving, so the rule is pinned +structurally too, the same treatment `new Date("2026-08-07")` gets. A bar also covers its +**last** day rather than stopping at that day's left edge; the alternative makes every span +one day short and a one-day task a zero-width line. + +**Found while building:** the fake's mirror of the new edge query hard-coded which column was +the blocker, so a mutant that swapped the SQL's two aliases — every arrow drawn backwards — +passed the whole suite. It now reads the roles and the membership tests off the statement, and +both mutants die behaviourally. + +### 11.18 WS-27r — the search surface, and the LIKE defect under it (built 2026-08-08) + +The last row of the parity backlog. *"`?q=` exists on the list endpoint; there is no search +surface."* Both halves turned out to be true, and the second one was worse than advertised. + +**`_` and `%` were live wildcards on every search anybody had done.** `build_task_filters` +bound `%{q}%` raw, so `_` — LIKE's single-character wildcard — meant `task_id` also matched +`taskXid` and `task-id`, and `50%` quietly meant `50`. In a workspace where people search for +identifiers all day that is a steady drip of hits nobody asked for, and it reads as fuzzy +matching rather than as a bug. `like_escape` fixes it **on the shared builder**, so the board +and every saved view get the fix, not only the new endpoint — fixing only the new code would +have left the bug exactly where people meet it. + +**Why a second endpoint, having twice argued against one.** The list answers *"which tasks +match these filters, in this order, on this page"*; search answers *"what did you mean"*. +It **ranks** — and the list's ordering is a column allowlist (`TASK_SORTS`) that deliberately +cannot express relevance, so a `sort=relevance` would be a sort key that only works when `q` +is present, a worse contract than a separate route. It is **capped, not paged**: nobody pages +through search results, they retype, and page 2 of a relevance ordering is where relevance has +run out. And it **names the project**, which the list does not because its caller already has +the tree. What decides *what a caller may see* is still shared — same +`task_visibility_clause`, same archived rule — so search can never surface what the list would +hide. That is the part that must not be duplicated; the rest is a different question. + +**Ranking happens in SQL, before the `LIMIT`.** Ranked afterwards over a capped set, the best +answer is only present if it was already inside the arbitrary fifty rows the database happened +to return — a defect that presents as "search is bad at long queries". Four tiers: the exact +task number, a title PREFIX, a title match, then description-only; ties break on recency, then +id, so a repeated search does not reshuffle. + +**`#42` is a task number.** People quote them, and a search box that returns every task whose +description mentions 42 has ignored what was typed. Bounded to eighteen digits — `task_number` +is a BIGINT, and an unbounded `int()` on user input is a parse nobody asked for. + +**A short query is empty, not a 422.** A search box types one character on the way to three, +and an error flashing on every keystroke is noise the user cannot act on. Below the minimum it +costs no database round trip at all. + +**Comments are deliberately not searched.** The largest text in the system and the least +likely to be what somebody is hunting by name; a comment hit would also have to render as its +task, which makes ranking across the two incomparable. Recorded so the absence reads as a +decision rather than an oversight. + +#### The palette + +`⌘K` from anywhere in Projects, not a search page: the question is *"where is that task"*, +asked while doing something else, usually about a project the person is not looking at. A page +makes finding something a place you navigate **to**, which is one navigation more than the +problem has. + +Four rules that only break under real typing speed on a real connection, so all four are +pure functions in `lib/search.ts` rather than something to click at: + +* **"No results" may be claimed only once, and never while a request is in flight.** Shown + during the gap it flashes between every keystroke and its answer — the commonest bug in + hand-rolled search UIs, and it reads as the search being broken rather than slow. The + previous results stay on screen while the next load runs, so the list does not blank and + re-fill under the cursor. +* **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. The endpoint echoes `query` back, so the guard needs no request + ids. +* **The arrows belong to the palette, unless a modifier is held.** Left to the browser they + move the text caret to the start or end of the query — two effects from one key. But + `Cmd+Left` is "go to line start", and stealing it breaks editing inside the palette's own + box. +* **The highlight needle is escaped before it becomes a regex.** Searching `(draft)` would + otherwise throw a syntax error and blank the palette — the browser-side twin of the very + LIKE defect this ticket fixed on the server. + +**Found by the live run, invisible to all 43 hermetic tests:** `:number IS NOT NULL` names no +column, so Postgres has nothing to infer the parameter's type from and asyncpg answers +`AmbiguousParameterError: could not determine data type of parameter $1` — the query never +runs. A Python fake has no type system to be ambiguous about. Fixed with an explicit +`CAST(:number AS bigint)` and pinned structurally, because that is the only level at which a +hermetic suite can hold it. + +**The fake learned to read LIKE properly.** `like_to_regex` translates `%`, `_` and the +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 — 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, + 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 6b8456d7..ba92e598 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -170,7 +170,7 @@ owning specs are the archive; this file owns ordering, gates and states only. | WS | Workstream | State | Owning spec · record | Gates · next (verified) | |---|---|---|---|---| -| WS-29 | **Multi-tenancy — turning CommandCenter into a product sold to other companies** | ◐ H1 scratch-done | **`specs/saas_multitenancy.md`** (architecture; §11 tickets) · ⭐ **`specs/saas_multitenancy_handover.md`** (H1→H8 runbook — hand THIS to the executing agent) · `specs/saas_multitenancy_implementation.md` (shapes) · board record 2026-08-09 in the parent spec | **Phase 0 ✅** (MT-0a · 0b · 0c-1 · 0d, pending review) · **H1 ✅ scratch-verified 2026-08-09**: 157/158/159 applied + idempotent re-run on a full-ladder (00→156) replica with a backfill-exercising seed; every runbook verify query correct; baseline 213 passed / 2 skipped. **Prod apply = owner's merge of PR #404**; verify by the three `- 15N_*.sql ... ok` deploy-log lines, never job conclusion. · MT-1: 1a schema ✅ (identity cutover = H6, open) · 1b generated ✅ · 1c seam + ratchets ✅ — **561 call sites across 138 files unconverted = H2, the long pole** · 1e wrapper ✅ (~58 key sites unconverted = H5) · 1i ✅ (two-org DB fixture owed) · **MT-2/MT-3 owner inputs ANSWERED 2026-08-09 (D18 → §8)** — spec detailing may start; MT-4 still needs the payment-provider split (§8 item 3) · 🔴 MT-0c-2 parked (D16; §6 first blockquote) · §5.1 cutover trigger **ADOPTED 2026-08-09**: ≥8 customers, or deploy overhead > ~1 day/month, or the first version-skew incident — owner checks monthly. **Next: owner merges #404 → H1 GATE passes → dispatch H2.** (2026-08-09) | +| WS-29 | **Multi-tenancy — turning CommandCenter into a product sold to other companies** | ◐ H1 scratch-done | **`specs/saas_multitenancy.md`** (architecture; §11 tickets) · ⭐ **`specs/saas_multitenancy_handover.md`** (H1→H8 runbook — hand THIS to the executing agent) · `specs/saas_multitenancy_implementation.md` (shapes) · board record 2026-08-09 in the parent spec | **Phase 0 ✅** (MT-0a · 0b · 0c-1 · 0d, pending review) · **H1 ✅ scratch-verified 2026-08-09**: 157/158/159 applied + idempotent re-run on a full-ladder (00→156) replica with a backfill-exercising seed; every runbook verify query correct; baseline 213 passed / 2 skipped. **Prod apply = owner's merge of PR #404**; verify by the three `- 15N_*.sql ... ok` deploy-log lines, never job conclusion. · MT-1: 1a schema ✅ (identity cutover = H6, open) · 1b generated ✅ · 1c seam + ratchets ✅ — **561 call sites across 138 files unconverted = H2, the long pole** · 1e wrapper ✅ (~58 key sites unconverted = H5) · 1i ✅ (two-org DB fixture owed) · **MT-2/MT-3 owner inputs ANSWERED 2026-08-09 (D18 → §8)** — spec detailing may start; MT-4 still needs the payment-provider split (§8 item 3) · 🔴 MT-0c-2 parked (D16; §6 first blockquote) · §5.1 cutover trigger **ADOPTED 2026-08-09**: ≥8 customers, or deploy overhead > ~1 day/month, or the first version-skew incident — owner checks monthly. **Next: owner merges #404 → H1 GATE passes → dispatch H2.** · ⚠️ **PR #399 carries a second, earlier WS-29** (`specs/multi_tenancy.md`, now marked superseded for architecture): migration **161** keys all 17 `pm_*` + a parent-consistency trigger, **162** makes `app_user` unique on `lower(email)` (byte-exact UNIQUE let one human be two rows in two orgs), S1-1 fixes a cross-tenant **write** into access control, S1-4 removes the process-global agent identity, plus a 14-finding leak audit. **It also found a defect in MT-1b:** the generator scoped `crm_contacts`/`crm_deals`/`crm_activities` by column name, but their `organization_id` references `crm_organizations` — phase 2 would have aborted mid-window. Gated at generation time now (`HOMONYM_BLOCKED`); those three tables carry **no isolation** pending a rename — owner call. (2026-08-09) | ### Apps @@ -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 · 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` instead of `core.task_visibility_clause` (found by n's tests). 🟡 **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. ⚠️ 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) | --- @@ -625,6 +625,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) > **WS-29 / MT-0c-2 — un-parking the WS-3 T2 container tier.** Still OWNER-GATE, and @@ -823,9 +827,20 @@ holder sees reorders under them; the dry-run (no `apply`) is agent-safe and is h proposal reaches the owner. If the tenant returns more than one pipeline the repair must STOP unapplied (spec D-CRM-11). Re-minting the Zoho token with `settings.*` scopes, should the probe report no-scope, is likewise the owner's act · -**the four WS-27 Projects gates** (`specs/project_management_app.md`), registered -2026-08-05: +**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**~~ — **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 +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 @@ -849,4 +864,14 @@ of record) — that amendment ships in the WS-27g PR, never before · **(d) granting `feature:projects` or `data:org:read` to any real member** on the live box — the same member/role-table write rule as WS-24 (d); the full-portfolio view is deliberately `data:org:read`'s first consumer, so granting it now grants -visibility that previously granted nothing. +visibility that previously granted nothing. · +~~**(e) answering D-PM-12 — whether a `blocks` dependency CONSTRAINS the schedule +or only describes it**~~ **ANSWERED 2026-08-08 and the gate is CLEARED.** The owner +was given the three options and their costs and delegated the choice back +(*"go ahead with the decision that you think would be best"*); recorded as +**D-PM-12 = (c) constrain-and-warn**, so WS-27p's "derived and shown, never +enforced" stands unamended and no cascade of writes was introduced. Kept struck +rather than deleted because the shape of the gate is the reusable part: an agent +must still refuse to make `blocks` **push dates** — moving to option (b) is a new +owner decision, not an extension of this one, and it would strike WS-27p's +paragraph rather than sit beside it. diff --git a/apps/agents/agent-crm/agents.py b/apps/agents/agent-crm/agents.py index eaa8a57f..d81d54f5 100644 --- a/apps/agents/agent-crm/agents.py +++ b/apps/agents/agent-crm/agents.py @@ -88,19 +88,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: @@ -142,8 +145,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/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/apps/services/gateway/gateway/routes/projects/__init__.py b/apps/services/gateway/gateway/routes/projects/__init__.py index c1a818cb..195022c6 100644 --- a/apps/services/gateway/gateway/routes/projects/__init__.py +++ b/apps/services/gateway/gateway/routes/projects/__init__.py @@ -25,12 +25,16 @@ 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 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 relations as _relations # noqa: F401 +from gateway.routes.projects import search as _search # 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/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/calendar.py b/apps/services/gateway/gateway/routes/projects/calendar.py new file mode 100644 index 00000000..0bec710f --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/calendar.py @@ -0,0 +1,297 @@ +"""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, + window_links, +) +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, + # WS-27t. Off by default: the calendar draws no arrows and would pay for a + # query it never reads. A flag rather than a second endpoint because the + # WINDOW is the resource — calendar and timeline are two renderings of the + # same question, and the app's standing rule (§11.8) is that a second + # endpoint per surface is how the filters start disagreeing. + include_links: 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), + # Always present, empty when not asked for: a missing key and an + # empty list read the same to a careless client, and "this window + # has no dependencies" must not be confused with "nobody asked". + "links": await window_links(db, window_rows) if include_links else [], + } + finally: + await db.close() + + +__all__ = [ + "MAX_WINDOW_DAYS", + "MAX_WINDOW_ROWS", + "OVERLAPS", + "UNDATED", + "parse_day", + "window_bounds", +] diff --git a/apps/services/gateway/gateway/routes/projects/core.py b/apps/services/gateway/gateway/routes/projects/core.py index d0fa98eb..1cfa69be 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 161 +#: 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 161'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)))" ) @@ -942,7 +1088,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/filters.py b/apps/services/gateway/gateway/routes/projects/filters.py index e7ea244d..bb469811 100644 --- a/apps/services/gateway/gateway/routes/projects/filters.py +++ b/apps/services/gateway/gateway/routes/projects/filters.py @@ -66,6 +66,23 @@ def validate_categories(values: list[str]) -> list[str]: return values +def like_escape(term: str) -> str: + """Neutralise LIKE's metacharacters in a term a human typed (WS-27r). + + ⚠️ **This was a live defect, not a precaution for new code.** `_` matches + any single character, so searching `task_id` also returned `taskXid` and + `task-id` — and in a workspace where people search for identifiers all day, + that is a steady drip of hits nobody asked for. `%` matches any run, so + `50%` quietly meant `50`. + + Backslash first, or escaping it afterwards would double the backslashes + this function has just introduced. Postgres' default LIKE escape is the + backslash and the pattern is BOUND rather than interpolated, so + `standard_conforming_strings` does not enter into it. + """ + return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def parse_when(raw: str, *, field: str) -> datetime: """A query-string timestamp → a real ``datetime``. @@ -182,7 +199,7 @@ def build_task_filters( if q and q.strip(): clauses.append("(t.title ILIKE :q OR t.description ILIKE :q)") - params["q"] = f"%{q.strip()}%" + params["q"] = f"%{like_escape(q.strip())}%" # WS-27m. TWO tag filters, because both questions get asked and one cannot # answer the other: `tags` is ANY (`&&` — "show me bugs or regressions"), @@ -265,6 +282,116 @@ 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 +""" + + +#: The `blocks` edges BETWEEN tasks in one window (WS-27t). +#: +#: **Both ends must be in the set**, because an arrow is drawn between two bars +#: and a bar that is not on screen has no end to attach to. The edge to an +#: off-window blocker is not lost, only undrawable — `blocked_by_count` already +#: puts a badge on the bar, which is the honest rendering of "something you +#: cannot see is holding this up". +#: +#: Only `blocks`. `relates_to` and `duplicates` are associations with no +#: direction that means anything to a schedule (WS-27p's `DIRECTED_TYPES`), and +#: drawing them as arrows would claim a sequence that was never asserted. +_WINDOW_LINKS_SQL = """ +SELECT l.id, l.source_task_id AS blocker, l.target_task_id AS blocked + FROM pm_task_links l + WHERE l.link_type = 'blocks' + AND l.source_task_id = ANY(CAST(:ids AS uuid[])) + AND l.target_task_id = ANY(CAST(:ids AS uuid[])) + ORDER BY l.id +""" + + +async def window_links(db: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """The drawable dependency edges among ``rows``, in ONE query. + + Returned beside the rows rather than folded into them: an edge belongs to + two tasks, and hanging it off one of them makes the client reconstruct the + other end — which is how a chart ends up drawing an arrow to a bar it has + already scrolled past. + """ + ids = [str(r["id"]) for r in rows if r.get("id")] + if not ids: + return [] + found = (await db.execute(text(_WINDOW_LINKS_SQL), {"ids": ids})).fetchall() + return [ + { + "id": str(r.id), + "blocker_id": str(r.blocker), + "blocked_id": str(r.blocked), + } + for r in found + ] + + +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/import_clickup.py b/apps/services/gateway/gateway/routes/projects/import_clickup.py index fee1467a..51853a55 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 161 §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 161'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..4d5f27ec 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 161). + "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 161 §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/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 fc0b6d34..1e6301e4 100644 --- a/apps/services/gateway/gateway/routes/projects/personal.py +++ b/apps/services/gateway/gateway/routes/projects/personal.py @@ -49,6 +49,8 @@ next_task_number, now, record_activity, + require_organization_of, + resolve_organization_id, resolve_visibility, router, row_to_dict, @@ -124,6 +126,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 +157,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 +171,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) @@ -359,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, @@ -380,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) @@ -429,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/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/apps/services/gateway/gateway/routes/projects/relations.py b/apps/services/gateway/gateway/routes/projects/relations.py new file mode 100644 index 00000000..ec056fec --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/relations.py @@ -0,0 +1,251 @@ +"""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, + t.start_date, t.due_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, + t.start_date, t.due_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, + t.start_date, t.due_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), + # WS-27t — the dates the schedule-conflict warning is computed from. + # Carried here so ONE pure rule serves the timeline's red arrow and the + # panel's sentence; 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": wire(row.start_date), + "due_at": wire(row.due_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), + "start_date": wire(r.start_date), "due_at": wire(r.due_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/search.py b/apps/services/gateway/gateway/routes/projects/search.py new file mode 100644 index 00000000..03152849 --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/search.py @@ -0,0 +1,186 @@ +"""Projects · the search surface (WS-27r). + +Spec: ``ai-company-brain/specs/project_management_app.md`` §11.2 item 10, §11.18. + + GET /projects/search?q=parser → ranked tasks, across every visible project + +*"`?q=` exists on the list endpoint; there is no search surface."* + +**Why this is not `GET /tasks?q=` with a nicer client.** The list endpoint +answers *"which tasks match these filters, in this order, on this page"*. Search +answers *"what did you mean"*, and the difference is not cosmetic: + +* **it ranks.** A task whose TITLE starts with the term is a better answer than + one that mentions it in paragraph four of its description. The list's + ordering is an allowlist of columns (`TASK_SORTS`) and deliberately cannot + express relevance — adding a `sort=relevance` would be a sort key that only + works when `q` is present, which is a worse contract than a second endpoint. +* **it is capped, not paged.** Nobody pages through search results; they retype. + A `LIMIT` with no `OFFSET` is the honest shape, and it keeps the ranking + meaningful — page 2 of a relevance ordering is where relevance has run out. +* **it names the project.** A hit is useless without knowing where it lives, + and the list endpoint returns `project_id` because its caller already has the + tree. A search palette does not. + +Everything that decides *what a caller may see* is still shared: +`task_visibility_clause` and the same archived rule, so search can never +surface a task the list would not. + +**`#123` is a task number, not a phrase.** A workspace numbers its tasks and +people quote those numbers; searching for `#42` and getting every task whose +description contains "42" is a search box that has ignored what you typed. + +**LIKE metacharacters are escaped** — see `like_escape`. That was a live defect +on the list endpoint too, not a new-code precaution. + +**Comments are deliberately not searched.** They are the largest text in the +system and the least likely to be what somebody is looking for by name; a +comment hit would also have to be rendered as its task, which makes ranking +across the two incomparable. Recorded so the absence reads as a decision. +""" + +from __future__ import annotations + +from acb_auth import UserContext, get_current_user +from fastapi import Depends +from gateway.routes.projects.core import ( + _get_db, + resolve_visibility, + router, + task_visibility_clause, + wire, +) +from gateway.routes.projects.filters import like_escape +from sqlalchemy import text + +#: Shorter than this and every query is a table scan returning half the +#: workspace. Answered as an EMPTY result rather than a 422: a search box types +#: one character on the way to typing three, and an error flashing in a palette +#: on every keystroke is noise the user cannot act on. +MIN_QUERY = 2 + +#: The most hits one search returns. Not a page — see the module docstring. +MAX_HITS = 50 + + +def task_number(raw: str) -> int | None: + """``#42`` or ``42`` → 42, else ``None``. + + Bounded to what the column can hold: `task_number` is a BIGINT, and a + forty-digit "number" is a phrase somebody typed, not a lookup. Without the + bound it would also be an unbounded integer parse on user input. + """ + stripped = raw.strip().lstrip("#").strip() + if not stripped.isdigit() or len(stripped) > 18: + return None + return int(stripped) + + +#: Ranked in SQL, because the ordering has to be applied BEFORE the limit. +#: +#: Ranking in Python over a capped set would sort whichever fifty rows the +#: database happened to return — so the best answer is only in the list if it +#: was already in the arbitrary fifty, which is the kind of bug that looks like +#: "search is bad at long queries" rather than like a defect. +#: +#: The four tiers, and why each earns its place above the next: +#: 0 the task number, typed exactly. Unambiguous, and never more than one. +#: 1 the title STARTS with the term — what somebody typing a name means. +#: 2 the title contains it. +#: 3 only the description contains it. +#: Ties break on recency, then on id so the order is total and a repeated +#: search does not reshuffle. +#: +#: ⚠️ `:number` is CAST explicitly. Without it Postgres has nothing to infer the +#: type from — `$1 IS NOT NULL` names no column — and asyncpg answers +#: `AmbiguousParameterError: could not determine data type of parameter $1`. +#: Every hermetic test passed with the cast missing, because a Python fake has +#: no type system to be ambiguous about; only the live run found it. +_SEARCH_SQL = """ +SELECT t.id, t.title, t.task_number, t.project_id, t.status_id, t.due_at, + t.completed_at, p.name AS project_name, s.name AS status_name, + s.category, + CASE + WHEN CAST(:number AS bigint) IS NOT NULL + AND t.task_number = CAST(:number AS bigint) THEN 0 + WHEN t.title ILIKE :prefix THEN 1 + WHEN t.title ILIKE :term THEN 2 + ELSE 3 + END AS rank + FROM pm_tasks t + JOIN pm_projects p ON p.id = t.project_id + JOIN pm_task_statuses s ON s.id = t.status_id + WHERE {visible} + AND t.archived_at IS NULL + AND (t.title ILIKE :term + OR t.description ILIKE :term + OR (CAST(:number AS bigint) IS NOT NULL + AND t.task_number = CAST(:number AS bigint))) + ORDER BY rank, t.updated_at DESC, t.id + LIMIT :cap +""" + + +@router.get("/search") +async def search_tasks( + q: str = "", + limit: int = MAX_HITS, + user: UserContext = Depends(get_current_user), +) -> dict: + """Ranked task hits across every project the caller can see. + + Global by default and by design: the point of a search surface is finding + the thing you cannot navigate to, and scoping it to the selected project + would make it a filter with a different name. + """ + term = q.strip() + if len(term) < MIN_QUERY: + return {"rows": [], "total": 0, "truncated": False, "query": term} + + cap = max(1, min(int(limit), MAX_HITS)) + escaped = like_escape(term) + + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + rows = (await db.execute( + text(_SEARCH_SQL.format(visible=task_visibility_clause(vis))), + { + **vis.params, + "term": f"%{escaped}%", + "prefix": f"{escaped}%", + "number": task_number(term), + # One more than the cap, so "there are more" is a fact rather + # than a guess — the WS-27q lesson: silence about truncation is + # the only unacceptable answer. + "cap": cap + 1, + }, + )).fetchall() + + hits = [ + { + "id": str(r.id), + "title": r.title, + "task_number": r.task_number, + "project_id": str(r.project_id), + "project_name": r.project_name, + "status_id": str(r.status_id), + "status_name": r.status_name, + "category": r.category, + "due_at": wire(r.due_at), + "completed_at": wire(r.completed_at), + "rank": int(r.rank), + } + for r in rows[:cap] + ] + return { + "rows": hits, + "total": len(hits), + "truncated": len(rows) > cap, + "query": term, + } + finally: + await db.close() + + +__all__ = ["MAX_HITS", "MIN_QUERY", "task_number"] diff --git a/apps/services/gateway/gateway/routes/projects/tasks.py b/apps/services/gateway/gateway/routes/projects/tasks.py index a428d85d..00497d72 100644 --- a/apps/services/gateway/gateway/routes/projects/tasks.py +++ b/apps/services/gateway/gateway/routes/projects/tasks.py @@ -59,9 +59,14 @@ 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 +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 @@ -196,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() @@ -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/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/apps/services/orchestrator/orchestrator/executor.py b/apps/services/orchestrator/orchestrator/executor.py index 1683c888..f99b3b7c 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. _release_run_credentials(_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/infra/postgres/160_projects_recurrence.sql b/infra/postgres/160_projects_recurrence.sql new file mode 100644 index 00000000..948abb60 --- /dev/null +++ b/infra/postgres/160_projects_recurrence.sql @@ -0,0 +1,114 @@ +-- 160_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/infra/postgres/161_projects_tenancy.sql b/infra/postgres/161_projects_tenancy.sql new file mode 100644 index 00000000..48c34a87 --- /dev/null +++ b/infra/postgres/161_projects_tenancy.sql @@ -0,0 +1,422 @@ +-- ============================================================================ +-- 161_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 was OPEN when this file was written, so it 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. +-- +-- ── ⚠️ D-MT-2 IS NOW ANSWERED, ON `main`, AND NOT BY ME (2026-08-09) ──────── +-- +-- A parallel workstream landed the answer in PR #404 while this branch was open: +-- **D15 — pooled, enforced by RLS** against a `app.tenant_id` GUC that +-- `acb_common.db.tenant_session()` sets (`SET LOCAL`, inside a transaction). +-- `specs/saas_multitenancy.md` is canonical; `specs/multi_tenancy.md` in this +-- branch is the earlier, narrower record and now defers to it. +-- +-- MT-1b generated the same column for **135 tables** — these 17 included — into +-- `infra/postgres/generated/{01_add_columns,02_backfill,03_constraints,04_policies}.sql`. +-- +-- **This file still stands, and here is the seam.** That generated set is +-- deliberately NOT a numbered migration: `apply_migrations.sh` does not replay +-- it, and promoting it is an act taken by hand in a maintenance window (its +-- generator's docstring names the 14h44m outage that makes that non-negotiable). +-- Meanwhile `routes/projects/core.py` reads `pm_*.organization_id` on every +-- request. A column the Projects app requires cannot wait on a maintenance +-- window, so the numbered path is what puts it there. +-- +-- The two compose, because both sides are `ADD COLUMN IF NOT EXISTS` — whichever +-- runs second finds the column and no-ops. **One difference is worth stating +-- rather than discovering:** the generated column carries +-- `DEFAULT current_setting('app.tenant_id', true)::uuid` and this one carries no +-- default. On a database where THIS migration ran first, the 17 `pm_*` tables +-- therefore fill `organization_id` from the parent-consistency trigger below +-- rather than from the session GUC. Both arrive at the same value under a bound +-- session; the trigger additionally REFUSES a child whose org disagrees with its +-- parent, which the GUC default cannot check. Nothing needs undoing when +-- `04_policies.sql` is promoted — RLS layers on top of a column that is already +-- NOT NULL and already correct. +-- +-- ── 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, 160 (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/infra/postgres/162_app_user_email_case.sql b/infra/postgres/162_app_user_email_case.sql new file mode 100644 index 00000000..e273e11f --- /dev/null +++ b/infra/postgres/162_app_user_email_case.sql @@ -0,0 +1,72 @@ +-- 162 — 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/infra/postgres/generated/01_add_columns.sql b/infra/postgres/generated/01_add_columns.sql index 00577f28..3d234cfe 100644 --- a/infra/postgres/generated/01_add_columns.sql +++ b/infra/postgres/generated/01_add_columns.sql @@ -6,7 +6,15 @@ -- -- Nullable ADD COLUMN. No table scan, no lock of consequence. Safe to apply on a live system. -- --- Tables in this phase: 135 +-- Tables in this phase: 133 +-- +-- ⚠️ NOT COVERED BY THIS FILE — `organization_id` already means something +-- else on these tables, so scoping them by that name would corrupt a +-- business column. They carry NO tenant isolation until the column is +-- renamed (owner call; see gen_tenant_migration.HOMONYM_BLOCKED): +-- crm_activities organization_id = the customer company (144_crm.sql:289) +-- crm_contacts organization_id = the customer company (144_crm.sql:74) +-- crm_deals organization_id = the customer company (144_crm.sql:197) -- -- ⚠️ NOT a numbered migration. `apply_migrations.sh` does not replay this -- directory. Promoting it is a deliberate act taken against a database in a @@ -107,14 +115,6 @@ ALTER TABLE copilot_event ADD COLUMN IF NOT EXISTS organization_id UUID DEFAULT current_setting('app.tenant_id', true)::uuid; -ALTER TABLE crm_activities - ADD COLUMN IF NOT EXISTS organization_id UUID - DEFAULT current_setting('app.tenant_id', true)::uuid; - -ALTER TABLE crm_contacts - ADD COLUMN IF NOT EXISTS organization_id UUID - DEFAULT current_setting('app.tenant_id', true)::uuid; - ALTER TABLE crm_deal_contacts ADD COLUMN IF NOT EXISTS organization_id UUID DEFAULT current_setting('app.tenant_id', true)::uuid; @@ -123,10 +123,6 @@ ALTER TABLE crm_deal_statuses ADD COLUMN IF NOT EXISTS organization_id UUID DEFAULT current_setting('app.tenant_id', true)::uuid; -ALTER TABLE crm_deals - ADD COLUMN IF NOT EXISTS organization_id UUID - DEFAULT current_setting('app.tenant_id', true)::uuid; - ALTER TABLE crm_lead_statuses ADD COLUMN IF NOT EXISTS organization_id UUID DEFAULT current_setting('app.tenant_id', true)::uuid; @@ -391,6 +387,10 @@ ALTER TABLE pm_projects ADD COLUMN IF NOT EXISTS organization_id UUID DEFAULT current_setting('app.tenant_id', true)::uuid; +ALTER TABLE pm_recurrences + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + ALTER TABLE pm_tags ADD COLUMN IF NOT EXISTS organization_id UUID DEFAULT current_setting('app.tenant_id', true)::uuid; diff --git a/infra/postgres/generated/02_backfill.sql b/infra/postgres/generated/02_backfill.sql index e3be4256..43f5adca 100644 --- a/infra/postgres/generated/02_backfill.sql +++ b/infra/postgres/generated/02_backfill.sql @@ -6,7 +6,15 @@ -- -- Batched UPDATE. Re-runnable and interruptible — each statement is idempotent, so a run that aborts can simply be run again. This is the slow phase; expect it to be the long pole on any table with real volume. -- --- Tables in this phase: 135 +-- Tables in this phase: 133 +-- +-- ⚠️ NOT COVERED BY THIS FILE — `organization_id` already means something +-- else on these tables, so scoping them by that name would corrupt a +-- business column. They carry NO tenant isolation until the column is +-- renamed (owner call; see gen_tenant_migration.HOMONYM_BLOCKED): +-- crm_activities organization_id = the customer company (144_crm.sql:289) +-- crm_contacts organization_id = the customer company (144_crm.sql:74) +-- crm_deals organization_id = the customer company (144_crm.sql:197) -- -- ⚠️ NOT a numbered migration. `apply_migrations.sh` does not replay this -- directory. Promoting it is a deliberate act taken against a database in a @@ -87,21 +95,12 @@ UPDATE copilot_config SET organization_id = (SELECT id FROM organization WHERE s UPDATE copilot_event SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; -UPDATE crm_activities SET organization_id = (SELECT id FROM organization WHERE slug = 'default') - WHERE organization_id IS NULL; - -UPDATE crm_contacts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') - WHERE organization_id IS NULL; - UPDATE crm_deal_contacts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; UPDATE crm_deal_statuses SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; -UPDATE crm_deals SET organization_id = (SELECT id FROM organization WHERE slug = 'default') - WHERE organization_id IS NULL; - UPDATE crm_lead_statuses SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; @@ -300,6 +299,9 @@ UPDATE pm_project_grants SET organization_id = (SELECT id FROM organization WHER UPDATE pm_projects 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; + UPDATE pm_tags SET organization_id = (SELECT id FROM organization WHERE slug = 'default') WHERE organization_id IS NULL; diff --git a/infra/postgres/generated/03_constraints.sql b/infra/postgres/generated/03_constraints.sql index 6c7dc620..ad7301f1 100644 --- a/infra/postgres/generated/03_constraints.sql +++ b/infra/postgres/generated/03_constraints.sql @@ -6,7 +6,15 @@ -- -- SET NOT NULL + FK + index. ⚠️ THIS IS THE ACCESS EXCLUSIVE PHASE — it scans each table. Apply in a window, table by table if necessary, and never behind a long-running transaction (see the generator docstring: that is the exact shape of the 14h44m outage). -- --- Tables in this phase: 135 +-- Tables in this phase: 133 +-- +-- ⚠️ NOT COVERED BY THIS FILE — `organization_id` already means something +-- else on these tables, so scoping them by that name would corrupt a +-- business column. They carry NO tenant isolation until the column is +-- renamed (owner call; see gen_tenant_migration.HOMONYM_BLOCKED): +-- crm_activities organization_id = the customer company (144_crm.sql:289) +-- crm_contacts organization_id = the customer company (144_crm.sql:74) +-- crm_deals organization_id = the customer company (144_crm.sql:197) -- -- ⚠️ NOT a numbered migration. `apply_migrations.sh` does not replay this -- directory. Promoting it is a deliberate act taken against a database in a @@ -291,30 +299,6 @@ ALTER TABLE copilot_event ADD CONSTRAINT copilot_event_org_fk FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS copilot_event_org_idx ON copilot_event (organization_id); --- crm_activities -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM crm_activities WHERE organization_id IS NULL) THEN - RAISE EXCEPTION 'MT-1b: crm_activities still has unowned rows — run phase 2 (backfill) to completion first'; - END IF; -END $$; -ALTER TABLE crm_activities ALTER COLUMN organization_id SET NOT NULL; -ALTER TABLE crm_activities ADD CONSTRAINT crm_activities_org_fk - FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; -CREATE INDEX IF NOT EXISTS crm_activities_org_idx ON crm_activities (organization_id); - --- crm_contacts -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM crm_contacts WHERE organization_id IS NULL) THEN - RAISE EXCEPTION 'MT-1b: crm_contacts still has unowned rows — run phase 2 (backfill) to completion first'; - END IF; -END $$; -ALTER TABLE crm_contacts ALTER COLUMN organization_id SET NOT NULL; -ALTER TABLE crm_contacts ADD CONSTRAINT crm_contacts_org_fk - FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; -CREATE INDEX IF NOT EXISTS crm_contacts_org_idx ON crm_contacts (organization_id); - -- crm_deal_contacts DO $$ BEGIN @@ -339,18 +323,6 @@ ALTER TABLE crm_deal_statuses ADD CONSTRAINT crm_deal_statuses_org_fk FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS crm_deal_statuses_org_idx ON crm_deal_statuses (organization_id); --- crm_deals -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM crm_deals WHERE organization_id IS NULL) THEN - RAISE EXCEPTION 'MT-1b: crm_deals still has unowned rows — run phase 2 (backfill) to completion first'; - END IF; -END $$; -ALTER TABLE crm_deals ALTER COLUMN organization_id SET NOT NULL; -ALTER TABLE crm_deals ADD CONSTRAINT crm_deals_org_fk - FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; -CREATE INDEX IF NOT EXISTS crm_deals_org_idx ON crm_deals (organization_id); - -- crm_lead_statuses DO $$ BEGIN @@ -1143,6 +1115,18 @@ ALTER TABLE pm_projects ADD CONSTRAINT pm_projects_org_fk FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS pm_projects_org_idx ON pm_projects (organization_id); +-- pm_recurrences +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_recurrences WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_recurrences still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_recurrences ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_recurrences ADD CONSTRAINT pm_recurrences_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_recurrences_org_idx ON pm_recurrences (organization_id); + -- pm_tags DO $$ BEGIN diff --git a/infra/postgres/generated/04_policies.sql b/infra/postgres/generated/04_policies.sql index 9bc6f21a..4861ecda 100644 --- a/infra/postgres/generated/04_policies.sql +++ b/infra/postgres/generated/04_policies.sql @@ -6,7 +6,15 @@ -- -- ENABLE + FORCE ROW LEVEL SECURITY + the policy. Instant — no scan. ⚠️ AND IT IS A CLIFF: the moment this applies, any connection that has not bound app.tenant_id reads ZERO ROWS. That is the fail-closed property working (§0.1). MT-1c must be deployed AND VERIFIED first, or the product goes dark. -- --- Tables in this phase: 135 +-- Tables in this phase: 133 +-- +-- ⚠️ NOT COVERED BY THIS FILE — `organization_id` already means something +-- else on these tables, so scoping them by that name would corrupt a +-- business column. They carry NO tenant isolation until the column is +-- renamed (owner call; see gen_tenant_migration.HOMONYM_BLOCKED): +-- crm_activities organization_id = the customer company (144_crm.sql:289) +-- crm_contacts organization_id = the customer company (144_crm.sql:74) +-- crm_deals organization_id = the customer company (144_crm.sql:197) -- -- ⚠️ NOT a numbered migration. `apply_migrations.sh` does not replay this -- directory. Promoting it is a deliberate act taken against a database in a @@ -187,20 +195,6 @@ CREATE POLICY copilot_event_tenant_isolation ON copilot_event USING (organization_id = current_setting('app.tenant_id', true)::uuid) WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); -ALTER TABLE crm_activities ENABLE ROW LEVEL SECURITY; -ALTER TABLE crm_activities FORCE ROW LEVEL SECURITY; -DROP POLICY IF EXISTS crm_activities_tenant_isolation ON crm_activities; -CREATE POLICY crm_activities_tenant_isolation ON crm_activities - USING (organization_id = current_setting('app.tenant_id', true)::uuid) - WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); - -ALTER TABLE crm_contacts ENABLE ROW LEVEL SECURITY; -ALTER TABLE crm_contacts FORCE ROW LEVEL SECURITY; -DROP POLICY IF EXISTS crm_contacts_tenant_isolation ON crm_contacts; -CREATE POLICY crm_contacts_tenant_isolation ON crm_contacts - USING (organization_id = current_setting('app.tenant_id', true)::uuid) - WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); - ALTER TABLE crm_deal_contacts ENABLE ROW LEVEL SECURITY; ALTER TABLE crm_deal_contacts FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS crm_deal_contacts_tenant_isolation ON crm_deal_contacts; @@ -215,13 +209,6 @@ CREATE POLICY crm_deal_statuses_tenant_isolation ON crm_deal_statuses USING (organization_id = current_setting('app.tenant_id', true)::uuid) WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); -ALTER TABLE crm_deals ENABLE ROW LEVEL SECURITY; -ALTER TABLE crm_deals FORCE ROW LEVEL SECURITY; -DROP POLICY IF EXISTS crm_deals_tenant_isolation ON crm_deals; -CREATE POLICY crm_deals_tenant_isolation ON crm_deals - USING (organization_id = current_setting('app.tenant_id', true)::uuid) - WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); - ALTER TABLE crm_lead_statuses ENABLE ROW LEVEL SECURITY; ALTER TABLE crm_lead_statuses FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS crm_lead_statuses_tenant_isolation ON crm_lead_statuses; @@ -684,6 +671,13 @@ CREATE POLICY pm_projects_tenant_isolation ON pm_projects USING (organization_id = current_setting('app.tenant_id', true)::uuid) WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); +ALTER TABLE pm_recurrences ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_recurrences FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_recurrences_tenant_isolation ON pm_recurrences; +CREATE POLICY pm_recurrences_tenant_isolation ON pm_recurrences + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + ALTER TABLE pm_tags ENABLE ROW LEVEL SECURITY; ALTER TABLE pm_tags FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS pm_tags_tenant_isolation ON pm_tags; 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/scripts/gen_tenant_migration.py b/scripts/gen_tenant_migration.py index 786ddce0..84a5caec 100644 --- a/scripts/gen_tenant_migration.py +++ b/scripts/gen_tenant_migration.py @@ -96,12 +96,73 @@ "both a performance cliff and a correctness hole when the parent is gone" ) +#: ⚠️ Tables that ALREADY have a column called ``organization_id`` meaning +#: something else entirely. **These are not exempt and they are not scoped — +#: they are BLOCKED**, and the difference matters: +#: +#: ``crm_contacts.organization_id`` is the customer COMPANY a contact works at +#: (``REFERENCES crm_organizations``), not the tenant that owns the row. The +#: generator's phases are name-based, so left alone they would emit, for each: +#: +#: phase 1 ADD COLUMN IF NOT EXISTS -> silent no-op, the column exists +#: phase 2 UPDATE ... WHERE organization_id IS NULL +#: -> writes a TENANT id into a column whose +#: FK points at ``crm_organizations``; +#: aborts on that FK, mid-window +#: phase 3 ADD CONSTRAINT ... REFERENCES organization(id) +#: -> a second, contradictory FK on one +#: column; fails on every existing value +#: +#: — i.e. the failure lands in the maintenance window, after phase 1 has run, +#: which is the worst moment to learn about it. Refusing at GENERATION time is +#: the whole point of this map. +#: +#: **These three tables therefore carry NO tenant isolation**, and they hold +#: customer CRM data. That is a real hole, not a resolved item. Closing it needs +#: an owner call this branch does not make: rename the CRM column +#: (``organization_id`` -> ``crm_organization_id``, touching every CRM route and +#: query), or give the tenant key a different name on these three tables alone +#: and accept that the column name means two things across the schema. Recorded +#: in ``specs/multi_tenancy_leak_audit.md``. +HOMONYM_BLOCKED: dict[str, str] = { + "crm_contacts": "organization_id = the customer company (144_crm.sql:74)", + "crm_deals": "organization_id = the customer company (144_crm.sql:197)", + "crm_activities": "organization_id = the customer company (144_crm.sql:289)", +} + _CREATE_RE = re.compile( r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?" r"(?:public\.)?[\"']?([a-z_][a-z0-9_]*)[\"']?", re.IGNORECASE, ) +#: A column literally named ``organization_id`` together with its FK target. The +#: name alone is not evidence of tenancy — matching on it is what let the homonym +#: through in the first place. +_ORG_COL_RE = re.compile( + r"^\s*organization_id\s+[A-Za-z]+[^,]*?REFERENCES\s+([a-z_][a-z0-9_]*)", + re.IGNORECASE | re.MULTILINE, +) + + +def discover_homonyms() -> dict[str, str]: + """Tables whose ``organization_id`` references something OTHER than + ``organization`` — derived from the migrations, never from a list. + + Derived on purpose: a hand-maintained list is exactly what + :data:`HOMONYM_BLOCKED` is for, and a list checking itself proves nothing. + This finds them; the map is the sign-off; :func:`main` refuses when the two + disagree. + """ + found: dict[str, str] = {} + for path in sorted(_MIGRATIONS.glob("[0-9]*_*.sql")): + parts = _CREATE_RE.split(path.read_text(encoding="utf-8")) + for name, body in zip(parts[1::2], parts[2::2], strict=True): + match = _ORG_COL_RE.search(body.split(";")[0]) + if match and match.group(1).lower() != "organization": + found[name.lower()] = match.group(1).lower() + return found + def discover_tables() -> list[str]: """Every table the numbered migrations create, in name order.""" @@ -112,6 +173,26 @@ def discover_tables() -> list[str]: return sorted(names) +def _blocked_note() -> str: + """The BLOCKED tables, in every generated file. + + In the header rather than a side document because the person reading these + files is mid-window with a psql prompt open, and "which tables did this NOT + cover" is the question they have no other way to answer. + """ + if not HOMONYM_BLOCKED: + return "" + lines = [ + "--", + "-- ⚠️ NOT COVERED BY THIS FILE — `organization_id` already means something", + "-- else on these tables, so scoping them by that name would corrupt a", + "-- business column. They carry NO tenant isolation until the column is", + "-- renamed (owner call; see gen_tenant_migration.HOMONYM_BLOCKED):", + ] + lines += [f"-- {t:<18} {why}" for t, why in sorted(HOMONYM_BLOCKED.items())] + return "\n".join(lines) + "\n" + + def _header(phase: str, why: str, tables: int) -> str: return f"""-- ============================================================================ -- MT-1b · phase {phase} — GENERATED, DO NOT EDIT BY HAND @@ -122,7 +203,7 @@ def _header(phase: str, why: str, tables: int) -> str: -- {why} -- -- Tables in this phase: {tables} --- +{_blocked_note()}-- -- ⚠️ NOT a numbered migration. `apply_migrations.sh` does not replay this -- directory. Promoting it is a deliberate act taken against a database in a -- maintenance window — see the module docstring of the generator for the @@ -228,7 +309,36 @@ def main() -> int: args = ap.parse_args() all_tables = discover_tables() - scoped = [t for t in all_tables if t not in EXEMPT] + + # ── The homonym gate, BEFORE anything is generated ────────────────────── + # + # Detection is derived from the migrations; HOMONYM_BLOCKED is the human + # sign-off. Refusing when they disagree is the only part that protects a + # table added next month, because that is the case where nobody remembers. + homonyms = discover_homonyms() + undeclared = sorted(set(homonyms) - set(HOMONYM_BLOCKED)) + if undeclared: + print("\n⚠️ REFUSING TO GENERATE. These tables have an `organization_id` " + "that references something other than `organization`:") + for t in undeclared: + print(f" {t:<24} organization_id -> {homonyms[t]}") + print("\nScoping them by column name would write a tenant id into a " + "business column and abort phase 2 mid-window. Either rename the " + "column, or declare the table in HOMONYM_BLOCKED with its reason.") + return 1 + stale = sorted(set(HOMONYM_BLOCKED) - set(homonyms)) + if stale: + print("\n⚠️ REFUSING TO GENERATE. These are declared in HOMONYM_BLOCKED " + "but no longer have a conflicting `organization_id`:") + for t in stale: + print(f" {t}") + print("\nIf the column was renamed, the table can now be scoped — drop " + "it from HOMONYM_BLOCKED so it rejoins the generated phases.") + return 1 + + blocked = [t for t in all_tables if t in HOMONYM_BLOCKED] + scoped = [t for t in all_tables + if t not in EXEMPT and t not in HOMONYM_BLOCKED] exempted = [t for t in all_tables if t in EXEMPT] print(f"discovered {len(all_tables)} tables in infra/postgres/[0-9]*.sql") @@ -236,6 +346,9 @@ def main() -> int: print(f" exempt : {len(exempted)}") for t in exempted: print(f" {t:<24} {EXEMPT[t]}") + print(f" ⚠️ BLOCKED : {len(blocked)} — no isolation, name collision") + for t in blocked: + print(f" {t:<24} {HOMONYM_BLOCKED[t]}") unknown = sorted(set(EXEMPT) - set(all_tables)) if unknown: print("\n ⚠️ exempt names that match no discovered table " 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 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]}" 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/_projects_fakes.py b/tests/unit/_projects_fakes.py index a4a562c0..3545a9a1 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 @@ -72,15 +80,141 @@ _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, +) +#: WS-27t: ``l.source_task_id AS blocker`` — which END plays which ROLE. Read +#: rather than assumed, because swapping the two aliases points every arrow the +#: wrong way and a mirror that hard-codes the roles cannot see it. +_ALIASED_END = re.compile( + r"l\.(source_task_id|target_task_id)\s+AS\s+(blocker|blocked)\b", re.I +) +#: WS-27t: which end is required to be inside the window. +_END_IN_WINDOW = re.compile( + r"l\.(source_task_id|target_task_id)\s*=\s*ANY\(CAST\(:ids", re.I +) #: 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 ) +# ── 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 +) +#: ⚠️ 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( + 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" + +#: Migration 161'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) +def like_to_regex(pattern: str) -> re.Pattern[str]: + """A SQL LIKE pattern → the regex it means, honouring the backslash escape. + + Written properly rather than as a `%`-strip-and-substring, because WS-27r's + whole defect was that `_` and `%` are METACHARACTERS: a fake that treated + the pattern as a literal substring would agree with both the escaped and + the unescaped implementation, and the bug this exists to catch would be + invisible here. + """ + out = ["(?is)^"] + escaped = False + for char in pattern: + if escaped: + out.append(re.escape(char)) + escaped = False + elif char == "\\": + escaped = True + elif char == "%": + out.append(".*") + elif char == "_": + out.append(".") + else: + out.append(re.escape(char)) + out.append("$") + return re.compile("".join(out)) + + def _strip_subqueries(where: str) -> tuple[str, list[str]]: """Split a WHERE into its top-level text and its subquery blocks. @@ -245,6 +379,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 @@ -260,6 +399,13 @@ def seed(self, table: str, **columns: Any) -> SimpleNamespace: **_DEFAULTS.get(table, {}), **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)) @@ -269,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 161'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] @@ -345,6 +512,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 @@ -361,6 +546,28 @@ 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)) + # WS-27t's drawable edges. Fingerprinted on `AS blocker,` — the count + # aggregate above uses `AS blocked,` and this one uses BOTH, so the + # order of these two branches is load-bearing and the more specific + # fingerprint has to be tested first. + if "AS blocker," in statement: + return _Result(self._window_links(statement, args)) + # WS-27r's ranked search. Three joins and a CASE — a shape the generic + # WHERE reader cannot parse, and one where `:number IS NOT NULL` would + # be mistaken for a column predicate and drop every row. + if "END AS rank" in statement: + return _Result(self._search_hits(statement, args)) head = statement.split(None, 1)[0].upper() table = self._table(statement) if head == "INSERT": @@ -377,6 +584,194 @@ 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 _search_hits(self, statement: str, args: dict) -> list[Any]: + """WS-27r's ranked hits. + + Every clause honoured only when the statement carries it — including + the visibility closure, so a search that loses it stops being scoped + here too and the leak test goes red. + """ + projects = {str(p["id"]): p for p in self.rows("pm_projects")} + statuses = {str(x["id"]): x for x in self.rows("pm_task_statuses")} + term = like_to_regex(str(args.get("term") or "")) + prefix = like_to_regex(str(args.get("prefix") or "")) + number = args.get("number") + 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 + ), + 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 + # 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: + continue + title = str(task.get("title") or "") + body = str(task.get("description") or "") + numbered = number is not None and task.get("task_number") == number + if not (term.match(title) or term.match(body) or numbered): + continue + rank = ( + 0 if numbered + else 1 if prefix.match(title) + else 2 if term.match(title) + else 3 + ) + project = projects.get(str(task.get("project_id")), {}) + status = statuses.get(str(task.get("status_id")), {}) + found.append(SimpleNamespace( + **task, + project_name=project.get("name"), + status_name=status.get("name"), + category=status.get("category"), + rank=rank, + )) + + # The tie-break is read off the statement, not assumed: `rank` alone + # leaves ties in seeding order, which would agree with any tie-break + # the route chose — including none. + recent_first = "t.updated_at DESC" in statement + found.sort(key=lambda r: str(r.id)) + if recent_first: + found.sort(key=lambda r: _sortable(r.updated_at), reverse=True) + found.sort(key=lambda r: r.rank) + cap = int(args.get("cap") or len(found)) + return found[:cap] + + def _window_links(self, statement: str, args: dict) -> list[Any]: + """The `blocks` edges with BOTH ends inside the page's ids. + + **Which column is the blocker and which columns are membership-tested + are both read off the statement**, never assumed. Assuming them was a + real mirror gap found by mutation: hard-coding `blocker=source` let a + mutant swap the SQL's two aliases — an arrow drawn the wrong way round, + the chart asserting the opposite sequence — with every test still green, + and counting the membership tests rather than naming their columns let a + mutant check the wrong end. + """ + wanted = {str(i) for i in (args.get("ids") or [])} + only_blocks = "l.link_type = 'blocks'" in statement + roles = {role: column for column, role in _ALIASED_END.findall(statement)} + checked = set(_END_IN_WINDOW.findall(statement)) + out: list[Any] = [] + for link in self.rows("pm_task_links"): + if only_blocks and link.get("link_type") != "blocks": + continue + ends = { + column: str(link.get(column) or "") + for column in ("source_task_id", "target_task_id") + } + if any(ends[column] not in wanted for column in checked): + continue + out.append(SimpleNamespace( + id=link.get("id"), + blocker=ends.get(roles.get("blocker", ""), ""), + blocked=ends.get(roles.get("blocked", ""), ""), + )) + return sorted(out, key=lambda r: str(r.id)) + + 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 @@ -423,6 +818,11 @@ def _insert(self, statement: str, table: str, args: dict) -> _Result: return _Result([SimpleNamespace(**row)]) row = {"id": str(uuid4()), **_DEFAULTS.get(table, {}), **values} + # Migration 161's `pm_organization_from_parent` trigger, mirrored: a + # 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.derive_organization(table, row) if table in _TIMESTAMPED: row.setdefault("created_at", _now()) row.setdefault("updated_at", _now()) @@ -501,17 +901,37 @@ 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, + descendant_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. + + ⚠️ 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 = { 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 @@ -523,11 +943,23 @@ 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 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: 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 @@ -568,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 @@ -647,13 +1088,47 @@ 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 + ), + 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" @@ -729,6 +1204,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 @@ -768,6 +1263,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_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" 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 3674c2e8..6aca39b0 100644 --- a/tests/unit/test_crm_agent.py +++ b/tests/unit/test_crm_agent.py @@ -276,7 +276,12 @@ 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 _INVOCATIONS[tool]() - assert "ACB_AGENT_USER_EMAIL" in str(exc.value) + # Not "ACB_AGENT_USER_EMAIL" any more: S1-4 deleted that fallback, because + # one process-global slot that no run ever cleared handed a run with no + # identity the LAST run's user — and under one-org-per-user that email IS + # the tenant. `_current_user_email` now resolves to "" and `_headers` + # refuses, so the message is about the missing actor, not the missing var. + assert "nobody to act as" in str(exc.value) assert calls == [], "the gateway was called despite having nobody to act as" diff --git a/tests/unit/test_org_access_control.py b/tests/unit/test_org_access_control.py index e0b60e72..68d43dca 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 ──────────────────────────────────────────────────────────────── @@ -639,6 +637,67 @@ def test_service_principal_runs_any_agent() -> None: 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/162_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 # ── Tenant predicate on the org_group joins (MT-1i) ───────────────────────── # # saas_multitenancy.md §6.4/§6.5 + tenancy_and_visibility.md §2. Decision D15 diff --git a/tests/unit/test_projects_attachments.py b/tests/unit/test_projects_attachments.py index bc62430d..ff8965ff 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 161 (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_calendar.py b/tests/unit/test_projects_calendar.py new file mode 100644 index 00000000..c97ac08b --- /dev/null +++ b/tests/unit/test_projects_calendar.py @@ -0,0 +1,642 @@ +"""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 itertools import pairwise +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) + + +# ── WS-27t — the drawable dependency edges ────────────────────────────────── + +@pytest.mark.asyncio +async def test_links_are_absent_unless_asked_for(db, events): + """⚠️ Always PRESENT, always empty until requested. A missing key and an + empty list read the same to a careless client, so "this window has no + dependencies" must not be confused with "nobody asked".""" + project, todo, _ = _workspace(db) + a = db.seed_task(project.id, todo.id, title="A", due_at="2026-08-05T10:00:00Z") + b = db.seed_task(project.id, todo.id, title="B", due_at="2026-08-10T10:00:00Z") + db.seed( + "pm_task_links", source_task_id=a.id, target_task_id=b.id, + link_type="blocks", created_by="owner@fracktal.in", + ) + + assert (await _window())["links"] == [] + assert len((await _window(include_links=True))["links"]) == 1 + + +@pytest.mark.asyncio +async def test_an_edge_names_both_ends_and_which_is_which(db, events): + project, todo, _ = _workspace(db) + a = db.seed_task(project.id, todo.id, title="A", due_at="2026-08-05T10:00:00Z") + b = db.seed_task(project.id, todo.id, title="B", due_at="2026-08-10T10:00:00Z") + db.seed( + "pm_task_links", source_task_id=a.id, target_task_id=b.id, + link_type="blocks", created_by="owner@fracktal.in", + ) + + edge = (await _window(include_links=True))["links"][0] + + # ⚠️ Named blocker/blocked rather than source/target: an arrow drawn the + # wrong way round is a chart confidently asserting the opposite sequence, + # and `source`/`target` do not say which is which to a reader. + assert edge["blocker_id"] == str(a.id) + assert edge["blocked_id"] == str(b.id) + + +@pytest.mark.asyncio +async def test_an_edge_to_a_task_outside_the_window_is_not_returned(db, events): + """⚠️ An arrow needs two bars. The edge is not lost — `blocked_by_count` + already badges the visible bar — but a half-edge would be drawn to a point + the chart has to invent.""" + project, todo, _ = _workspace(db) + inside = db.seed_task( + project.id, todo.id, title="Inside", due_at="2026-08-10T10:00:00Z", + ) + outside = db.seed_task( + project.id, todo.id, title="Outside", due_at="2026-12-10T10:00:00Z", + ) + db.seed( + "pm_task_links", source_task_id=outside.id, target_task_id=inside.id, + link_type="blocks", created_by="owner@fracktal.in", + ) + + result = await _window(include_links=True) + + assert result["links"] == [] + # The information survives as a badge rather than as an arrow. + assert result["rows"][0]["blocked_by_count"] == 1 + + +@pytest.mark.asyncio +async def test_only_blocks_is_drawn_as_an_arrow(db, events): + """`relates_to` and `duplicates` are associations with no direction that + means anything to a schedule (WS-27p's `DIRECTED_TYPES`). Drawing them + would claim a sequence nobody asserted.""" + project, todo, _ = _workspace(db) + a = db.seed_task(project.id, todo.id, title="A", due_at="2026-08-05T10:00:00Z") + b = db.seed_task(project.id, todo.id, title="B", due_at="2026-08-10T10:00:00Z") + for link_type in ("relates_to", "duplicates"): + db.seed( + "pm_task_links", source_task_id=a.id, target_task_id=b.id, + link_type=link_type, created_by="owner@fracktal.in", + ) + + assert (await _window(include_links=True))["links"] == [] + + +@pytest.mark.asyncio +async def test_the_edges_are_one_query_for_the_whole_window(db, events): + project, todo, _ = _workspace(db) + made = [ + db.seed_task( + project.id, todo.id, title=f"T{n}", due_at=f"2026-08-1{n}T10:00:00Z", + ) + for n in range(6) + ] + for earlier, later in pairwise(made): + db.seed( + "pm_task_links", source_task_id=earlier.id, target_task_id=later.id, + link_type="blocks", created_by="owner@fracktal.in", + ) + + db.statements.clear() + result = await _window(include_links=True) + + assert len(result["links"]) == 5 + # `"AS blocker"` would ALSO match `_BLOCKER_COUNTS_SQL`'s `AS blockers` — + # the same substring collision the fake's own dispatch has to avoid, and it + # bit this assertion first. The trailing comma is what makes it specific. + assert len([s for s in db.statements if "AS blocker," in s]) == 1 + + +def test_the_edge_query_requires_both_ends_in_the_window() -> None: + """Structural: the fake mirrors this in Python, so only the statement can + say whether the route still asks for it.""" + source = Path( + "apps/services/gateway/gateway/routes/projects/filters.py" + ).read_text(encoding="utf-8") + match = re.search(r"_WINDOW_LINKS_SQL = \"\"\"(.*?)\"\"\"", source, re.S) + assert match is not None + body = match.group(1) + assert body.count("= ANY(CAST(:ids AS uuid[]))") == 2 + assert "l.link_type = 'blocks'" in body 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/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_migration.py b/tests/unit/test_projects_migration.py index a4bc7fac..43ee91ef 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 161) ────────────────────────────────── +# +# 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/160. +#: 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_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_projects_recurrence.py b/tests/unit/test_projects_recurrence.py new file mode 100644 index 00000000..a95d54a8 --- /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/160_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, "160 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, "160 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, "160 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"160 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/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/tests/unit/test_projects_search.py b/tests/unit/test_projects_search.py new file mode 100644 index 00000000..87a9e431 --- /dev/null +++ b/tests/unit/test_projects_search.py @@ -0,0 +1,404 @@ +"""WS-27r — the search surface, and the LIKE defect it uncovered. + +Spec: ``ai-company-brain/specs/project_management_app.md`` §11.2 item 10, §11.18. + +The last row of the parity backlog. `?q=` has existed on the list endpoint +since WS-27a; what was missing was a way to reach it, and — it turned out — an +escape. + +The claims worth pinning: + +* **`_` and `%` are LITERAL when a human types them.** This was live: searching + `task_id` also matched `taskXid`, because `_` is LIKE's single-character + wildcard. In a workspace where people search for identifiers all day that is + a steady drip of hits nobody asked for, and it looks like fuzzy matching + rather than like a bug. +* **ranking happens in SQL, before the LIMIT.** Ranked afterwards, the best + answer is only in the list if it was already inside the arbitrary fifty rows + the database happened to return — which reads as "search is bad at long + queries" rather than as a defect. +* **`#42` is a task number.** Getting every task whose description mentions 42 + is a search box that ignored what you typed. +* **a short query is empty, not a 422.** A search box types one character on + the way to three, and an error flashing on every keystroke is noise. +* **search can never surface what the list would hide.** Same visibility + clause, same archived rule. +""" + +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 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 gateway.routes.projects.filters import like_escape +from gateway.routes.projects.search import MAX_HITS, MIN_QUERY, task_number + +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_search, +) +USER = projects_user() +MEMBER = member_user("colleague@fracktal.in") + +SOURCE = Path("apps/services/gateway/gateway/routes/projects/search.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) + + +# ── like_escape — the defect ──────────────────────────────────────────────── + +def test_an_underscore_is_a_literal_not_a_wildcard() -> None: + """⚠️ Live defect. `task_id` matched `taskXid` and `task-id`.""" + assert like_escape("task_id") == r"task\_id" + + +def test_a_percent_is_a_literal_too() -> None: + """`50%` quietly meant `50`, so it matched `500` and `1502`.""" + assert like_escape("50%") == r"50\%" + + +def test_a_backslash_is_escaped_FIRST() -> None: + """⚠️ Escaping the backslash last would double every backslash this + function had just introduced, and `_` would come back out as a wildcard.""" + assert like_escape(r"a\_b") == r"a\\\_b" + + +def test_a_plain_term_is_left_completely_alone() -> None: + assert like_escape("parser refactor") == "parser refactor" + + +@pytest.mark.parametrize("term", ["", "%", "_", "\\", "%%__\\\\"]) +def test_escaping_is_total_no_metacharacter_survives_unescaped(term: str) -> None: + """Every metacharacter in the output is preceded by a backslash. Written as + a property rather than as four examples, because the failure mode is one + character somebody forgot.""" + escaped = like_escape(term) + for index, char in enumerate(escaped): + if char in "%_": + assert escaped[index - 1] == "\\", escaped + + +def test_the_list_endpoint_escapes_too_not_only_search() -> None: + """⚠️ The defect was on `build_task_filters`, which the board and every + saved view read through. Fixing only the new endpoint would leave the bug + exactly where people meet it.""" + source = Path( + "apps/services/gateway/gateway/routes/projects/filters.py" + ).read_text(encoding="utf-8") + assert 'params["q"] = f"%{like_escape(q.strip())}%"' in source + + +# ── task_number ───────────────────────────────────────────────────────────── + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("#42", 42), ("42", 42), (" #42 ", 42), ("# 42", 42), + ("parser", None), ("", None), ("#", None), ("4.2", None), + ("-1", None), ("42a", None), + ], +) +def test_a_task_number_is_recognised_only_when_it_is_one(raw, expected) -> None: + assert task_number(raw) == expected + + +def test_an_absurdly_long_digit_string_is_a_phrase_not_a_number() -> None: + """`task_number` is a BIGINT. An unbounded `int()` on user input is a parse + nobody asked for, and the result could not be compared to the column.""" + assert task_number("9" * 40) is None + + +# ── The query, structurally ───────────────────────────────────────────────── + +def test_ranking_happens_in_SQL_before_the_limit() -> None: + """⚠️ Ranked in Python over a capped set, the best answer is only present + if it was already inside the arbitrary fifty rows returned.""" + source = SOURCE.read_text(encoding="utf-8") + query = re.search(r"_SEARCH_SQL = \"\"\"(.*?)\"\"\"", source, re.S) + assert query is not None + body = query.group(1) + assert "ORDER BY rank" in body + assert body.index("ORDER BY") < body.index("LIMIT") + assert "OFFSET" not in body + + +def test_each_rank_is_paired_with_the_right_test() -> None: + """⚠️ Asserting only that `THEN 0/1/2` appear in order is not enough: the + tiers can be swapped by exchanging their WHEN clauses, leaving the THENs + exactly where they were. A mutant did precisely that and survived. The + pairing is the claim.""" + source = SOURCE.read_text(encoding="utf-8") + query = re.search(r"CASE(.*?)END AS rank", source, re.S).group(1) + assert re.search(r"t\.task_number = CAST\(:number AS bigint\) THEN 0", query) + assert re.search(r"t\.title ILIKE :prefix THEN 1", query) + assert re.search(r"t\.title ILIKE :term THEN 2", query) + + +def test_the_number_parameter_is_CAST_so_its_type_is_inferable() -> None: + """⚠️ Found by the live run, invisible to every hermetic test. + + `:number IS NOT NULL` names no column, so Postgres has nothing to infer the + parameter's type from and asyncpg answers `AmbiguousParameterError: could + not determine data type of parameter $1` — the query never runs. A Python + fake has no type system to be ambiguous about, so all 43 tests here passed + with the cast missing. Structural, because that is the only level at which + this suite can hold it. + """ + source = SOURCE.read_text(encoding="utf-8") + query = re.search(r"_SEARCH_SQL = \"\"\"(.*?)\"\"\"", source, re.S).group(1) + for reference in re.finditer(r":number", query): + window = query[max(0, reference.start() - 24):reference.end()] + assert "CAST(" in window, f"bare :number at {reference.start()}" + assert "AS bigint)" in query + + +def test_the_search_carries_the_visibility_clause() -> None: + """⚠️ A new query is a new place to forget it, and search reaches EVERY + project by design — so a missing clause here leaks the whole workspace.""" + source = SOURCE.read_text(encoding="utf-8") + assert "{visible}" in source + assert "task_visibility_clause(vis)" in source + + +def test_the_search_hides_archived_tasks() -> None: + source = SOURCE.read_text(encoding="utf-8") + assert "t.archived_at IS NULL" in source + + +def test_the_cap_is_probed_by_one_so_truncation_is_a_fact() -> None: + source = SOURCE.read_text(encoding="utf-8") + assert '"cap": cap + 1' in source + + +def test_search_writes_nothing() -> None: + """A read surface. A POST here would be a second write path to something.""" + source = SOURCE.read_text(encoding="utf-8") + assert not re.search(r"@router\.(post|patch|put|delete)", source) + + +def test_the_route_is_actually_mounted() -> None: + from gateway.routes.projects import router + + assert "/projects/search" in {route.path for route in router.routes} + + +# ── 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) + return project, todo + + +@pytest.mark.asyncio +async def test_a_title_match_comes_back_with_its_project_named(db, events): + """A hit is useless without knowing where it lives — the palette has no + tree to look it up in.""" + project, todo = _workspace(db) + db.seed_task(project.id, todo.id, title="Refactor the parser", task_number=7) + + result = await pm_search.search_tasks(q="parser", user=USER) + + assert [r["title"] for r in result["rows"]] == ["Refactor the parser"] + assert result["rows"][0]["project_name"] == "Ops" + assert result["rows"][0]["task_number"] == 7 + + +@pytest.mark.asyncio +async def test_a_title_prefix_outranks_a_title_mention_outranks_a_description( + db, events, +): + """⚠️ The whole reason search is not the list endpoint. Somebody typing + "parser" means the thing CALLED that, and a mutant that merely swapped the + two title tiers survived until this fixture existed.""" + project, todo = _workspace(db) + # `updated_at` is set explicitly because it is the SQL's tie-break, and a + # fixture that left it to chance would assert whatever order the seeding + # happened to produce. + db.seed_task(project.id, todo.id, title="Older title mention of parser", + updated_at="2026-08-01T00:00:00Z") + db.seed_task(project.id, todo.id, title="Newer title mention of parser", + updated_at="2026-08-05T00:00:00Z") + db.seed_task(project.id, todo.id, title="Parser rewrite", + updated_at="2026-07-01T00:00:00Z") + db.seed_task(project.id, todo.id, title="Beta unrelated", + description="the parser is mentioned here", + updated_at="2026-08-09T00:00:00Z") + + result = await pm_search.search_tasks(q="parser", user=USER) + + assert [(r["rank"], r["title"]) for r in result["rows"]] == [ + # Rank beats recency: the prefix hit is the OLDEST row here and still + # comes first, so a mutant that sorted by date alone cannot pass. + (1, "Parser rewrite"), + (2, "Newer title mention of parser"), + (2, "Older title mention of parser"), + (3, "Beta unrelated"), + ] + + +@pytest.mark.asyncio +async def test_the_exact_task_number_outranks_everything(db, events): + project, todo = _workspace(db) + db.seed_task(project.id, todo.id, title="42 ways to refactor", task_number=1) + db.seed_task(project.id, todo.id, title="Unrelated work", task_number=42) + + result = await pm_search.search_tasks(q="#42", user=USER) + + assert result["rows"][0]["title"] == "Unrelated work" + assert result["rows"][0]["rank"] == 0 + + +@pytest.mark.asyncio +async def test_an_underscore_does_not_match_any_character(db, events): + """⚠️ The live defect, end to end. Before `like_escape`, searching for the + identifier `task_id` also returned `taskXid`.""" + project, todo = _workspace(db) + db.seed_task(project.id, todo.id, title="Rename task_id everywhere") + db.seed_task(project.id, todo.id, title="Rename taskXid everywhere") + + result = await pm_search.search_tasks(q="task_id", user=USER) + + assert [r["title"] for r in result["rows"]] == ["Rename task_id everywhere"] + + +@pytest.mark.asyncio +async def test_a_percent_sign_is_searched_for_literally(db, events): + project, todo = _workspace(db) + db.seed_task(project.id, todo.id, title="Cut latency 50% by Friday") + db.seed_task(project.id, todo.id, title="Ship 500 units") + + result = await pm_search.search_tasks(q="50%", user=USER) + + assert [r["title"] for r in result["rows"]] == ["Cut latency 50% by Friday"] + + +@pytest.mark.asyncio +async def test_a_short_query_is_empty_rather_than_an_error(db, events): + """⚠️ A search box types one character on the way to three. A 422 flashing + on every keystroke is noise the user cannot act on.""" + project, todo = _workspace(db) + db.seed_task(project.id, todo.id, title="Anything") + + result = await pm_search.search_tasks(q="a", user=USER) + + assert result == {"rows": [], "total": 0, "truncated": False, "query": "a"} + # And it cost nothing: no database round trip at all. + assert db.statements == [] + + +@pytest.mark.asyncio +async def test_a_whitespace_only_query_is_not_a_search(db, events): + result = await pm_search.search_tasks(q=" ", user=USER) + assert result["rows"] == [] + assert db.statements == [] + + +@pytest.mark.asyncio +async def test_the_minimum_is_exactly_two_characters(db, events): + _workspace(db) + await pm_search.search_tasks(q="ab", user=USER) + assert db.statements != [], f"{MIN_QUERY} characters must reach the database" + + +@pytest.mark.asyncio +async def test_an_archived_task_is_not_findable(db, events): + project, todo = _workspace(db) + db.seed_task( + project.id, todo.id, title="Old parser work", + archived_at="2026-08-01T00:00:00Z", + ) + + assert (await pm_search.search_tasks(q="parser", user=USER))["rows"] == [] + + +@pytest.mark.asyncio +async def test_search_cannot_reach_a_project_the_caller_lacks(db, events): + """⚠️ Search spans EVERY project by design, which makes it the endpoint + where a missing visibility clause leaks the most.""" + db.seed_project(name="Ops", subject="colleague@fracktal.in") + secret = db.seed_project(name="Secret", subject=None) + status = db.seed_status(secret.id, name="To do", category="todo") + db.seed_task(secret.id, status.id, title="Confidential parser rewrite") + + result = await pm_search.search_tasks(q="parser", user=MEMBER) + + assert result["rows"] == [] + + +@pytest.mark.asyncio +async def test_the_limit_is_clamped_to_the_maximum(db, events): + """A client asking for ten thousand gets fifty, because the cap is the + endpoint's promise rather than the caller's preference.""" + _workspace(db) + await pm_search.search_tasks(q="parser", limit=10_000, user=USER) + bound = next(args for stmt, args in db.calls if "AS rank" in stmt) + assert bound["cap"] == MAX_HITS + 1 + + +@pytest.mark.asyncio +async def test_a_nonsense_limit_still_asks_for_at_least_one(db, events): + _workspace(db) + await pm_search.search_tasks(q="parser", limit=0, user=USER) + bound = next(args for stmt, args in db.calls if "AS rank" in stmt) + assert bound["cap"] == 2 + + +@pytest.mark.asyncio +async def test_the_bound_pattern_is_escaped_not_raw(db, events): + """The end-to-end version of the escaping claim: what actually reaches the + database has the metacharacter neutralised.""" + _workspace(db) + await pm_search.search_tasks(q="task_id", user=USER) + bound = next(args for stmt, args in db.calls if "AS rank" in stmt) + assert bound["term"] == r"%task\_id%" + assert bound["prefix"] == r"task\_id%" + + +@pytest.mark.asyncio +async def test_a_number_query_binds_the_number_and_the_text(db, events): + """`#42` should find task 42 AND anything that mentions 42 — ranked with + the exact number first. Binding only one of the two would lose half the + answer.""" + _workspace(db) + await pm_search.search_tasks(q="#42", user=USER) + bound = next(args for stmt, args in db.calls if "AS rank" in stmt) + assert bound["number"] == 42 + assert bound["term"] == "%#42%" + + +@pytest.mark.asyncio +async def test_a_word_query_binds_a_null_number(db, events): + """NULL rather than absent: the statement references `:number` three times + and a missing bind is a driver error, not a skipped clause.""" + _workspace(db) + await pm_search.search_tasks(q="parser", user=USER) + bound = next(args for stmt, args in db.calls if "AS rank" in stmt) + assert bound["number"] is None diff --git a/tests/unit/test_projects_tenancy.py b/tests/unit/test_projects_tenancy.py new file mode 100644 index 00000000..511a4606 --- /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 161. + +``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 161'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 new file mode 100644 index 00000000..a4c258b9 --- /dev/null +++ b/tests/unit/test_tenancy_boundary.py @@ -0,0 +1,394 @@ +"""The tenant boundary, as a ratchet (WS-29). + +⚠️ **113 of CommandCenter's 146 tables still 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 147th. + +**137 → 123 → 113.** WS-29a keyed all 17 `pm_*` tables (migration 161) while +they were still empty enough to make it a one-line default rather than a +backfill. Then `main`'s MT-0d keyed `provider_keys`, `model_config`, +`mcp_servers` and `plugins`, and MT-1a introduced a control plane that is +cross-tenant on purpose. The ratchet is what turns each of those into a +*required* edit: the "gained a key, leave the baseline" rule below goes red the +moment a migration lands, and stays red until this file and the schema agree. + +## Why this file exists ALONGSIDE `test_tenant_coverage.py` + +They are not duplicates, and the difference is the whole reason this one +survived the merge with `main`. + +`test_tenant_coverage.py` (MT-1b) asks whether the **generator** covers every +table — it reads `gen_tenant_migration.discover_tables()` and the `EXEMPT` map. +It never looks at what an existing `organization_id` actually *references*. + +This file matches the **foreign key's target**, and that distinction is not +hypothetical: + + crm_contacts.organization_id REFERENCES crm_organizations -- a CUSTOMER + pm_tasks.organization_id REFERENCES organization -- the TENANT + +Three CRM tables carry the first kind. A name-based check reads them as scoped; +they are not scoped at all. That mistake was in the first version of THIS file +too — it published "6 tables scoped" when the answer was 3 — and matching the FK +target is what corrected it. The same blind spot then turned out to be live in +MT-1b's generator, which would have emitted `UPDATE crm_contacts SET +organization_id = ` into a column whose FK points at `crm_organizations` +and aborted phase 2 in the maintenance window. `HOMONYM_BLOCKED` is the fix; +the assertions below are what stop it coming back. + +## The rules + + * a table **not** in the baseline, not exempt and not blocked must carry a + real tenant FK — 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 above is always the real one; + * every discovered table lands in exactly one of the four buckets — the + partition `test_tenant_coverage.py`'s docstring promises and its assertions + do not actually check. + +That third rule is what makes the others 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. D-MT-2 — open when this file was written — was **answered on `main` +as D15: pooled, enforced by RLS** against the `app.tenant_id` GUC that +`acb_common.db.tenant_session()` binds. `specs/saas_multitenancy.md` is +canonical for that. The column is what every one of those designs wanted, so +nothing here changes because of it. +""" + +from __future__ import annotations + +import glob +import importlib.util +import os +import re +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[2] + +#: `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" + + +def _generator(): + """Import ``scripts/gen_tenant_migration.py`` (not an installed package). + + Read rather than copied: `EXEMPT` and `HOMONYM_BLOCKED` are decisions, and a + second hand-maintained copy of a decision is a copy that will disagree. + """ + path = _REPO / "scripts" / "gen_tenant_migration.py" + spec = importlib.util.spec_from_file_location("gen_tenant_migration", path) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + sys.modules["gen_tenant_migration"] = mod + spec.loader.exec_module(mod) + return mod + + +#: Tables that carry a tenant key today. Not a baseline — the goal state. +EXPECTED_SCOPED = { + "app_user", + "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 at 113. Every table predating the multi-tenant decision that is +#: neither exempt (a deliberate cross-tenant table — see the generator's +#: `EXEMPT`) nor blocked (a name collision — see `HOMONYM_BLOCKED`). +#: +#: Adding a name here is allowed and must come with a reason in the PR; adding +#: one *silently* is how a 113 becomes a 140 without anybody choosing it. +#: +#: Nothing that appears in the generator's `EXEMPT` or `HOMONYM_BLOCKED` belongs +#: here — one table, one bucket, asserted below. +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_activities`, `crm_contacts` and `crm_deals` are NOT here. + # They carry a column called `organization_id` that REFERENCES + # crm_organizations, a CUSTOMER COMPANY, so they are neither scoped nor + # ordinary debt: they are BLOCKED in `gen_tenant_migration.HOMONYM_BLOCKED` + # until the column is renamed, because a generator that scopes by name + # would corrupt a business column. + "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", +# 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", +# meeting_* + "meeting", "meeting_bot", "meeting_note", "meeting_recording", +# message_* + "message", +# notes_* + "notes_glossary", +# org_* + "org_group_member", "org_role_permission", "org_settings", +# pending_* + "pending_actions", "pending_commit", +# person_* + "person", +# pm_* +# — all 17 left this baseline in WS-29a (migration 161). They are asserted +# as scoped by `EXPECTED_SCOPED` above, so their absence here is checked +# rather than merely assumed. +# project_* + "project", +# 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",} + + +#: ``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. + """ + 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. + + 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 _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): + 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 + + +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.""" + 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. + """ + gen = _generator() + tables, scoped = _scan() + unscoped = tables - scoped - {t for t in tables if t.startswith(FOREIGN_PREFIX)} + new = sorted(unscoped - BASELINE_UNSCOPED - set(gen.EXEMPT) + - set(gen.HOMONYM_BLOCKED)) + assert not new, ( + f"{new} has no `organization_id` REFERENCING `organization`. " + f"CommandCenter is multi-tenant (specs/saas_multitenancy.md): give it " + f"one, or add it to 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_every_table_lands_in_exactly_one_bucket() -> None: + """The partition, which is what makes the count above mean anything. + + ⚠️ This is the assertion `test_tenant_coverage.py`'s own docstring promises + ("every table is either in EXEMPT or will be scoped") and does not make — + its source-level test checks that exempt entries have *reasons* and that + discovery found more than 100 tables, neither of which fails when a table + silently belongs to no bucket at all. + """ + gen = _generator() + tables, scoped = _scan() + buckets = { + "scoped": scoped, + "baseline": BASELINE_UNSCOPED, + "exempt": set(gen.EXEMPT), + "blocked": set(gen.HOMONYM_BLOCKED), + } + known = {t for t in tables if not t.startswith(FOREIGN_PREFIX)} + homeless = sorted(known - set().union(*buckets.values())) + assert not homeless, f"{homeless} belongs to no bucket" + for a, b in (("baseline", "exempt"), ("baseline", "blocked"), + ("exempt", "blocked"), ("scoped", "blocked")): + both = sorted(buckets[a] & buckets[b] & known) + assert not both, f"{both} is in both {a} and {b} — one table, one bucket" + + +def test_the_blocked_list_is_derived_not_asserted() -> None: + """⚠️ The homonym gate, checked in CI without running the generator. + + `discover_homonyms()` reads the migrations; `HOMONYM_BLOCKED` is the human + sign-off. The generator refuses to emit when they disagree — this asserts + the same thing at test time, so a fourth homonym table added next month is + caught by a red build rather than by a failed apply in a window. + """ + gen = _generator() + found = gen.discover_homonyms() + assert set(found) == set(gen.HOMONYM_BLOCKED), ( + f"detected {sorted(found)} but HOMONYM_BLOCKED declares " + f"{sorted(gen.HOMONYM_BLOCKED)}. A table whose `organization_id` points " + f"somewhere other than `organization` cannot be scoped by that name." + ) + for name in found: + assert gen.HOMONYM_BLOCKED[name].strip(), ( + f"{name} is blocked with no reason given. The reason is the whole " + f"artefact: it is what tells the next reader this is an unclosed " + f"hole rather than a decision somebody already made." + ) + + +def test_an_exemption_cannot_launder_a_homonym() -> None: + """`EXEMPT` means "cross-tenant by design". `HOMONYM_BLOCKED` means "we + cannot scope this yet and it is a hole". Filing the second as the first + would make a real gap read as a resolved decision — and `EXEMPT` is the map + a reviewer is told to challenge, so it is exactly where it would hide.""" + gen = _generator() + overlap = sorted(set(gen.EXEMPT) & set(gen.discover_homonyms())) + assert not overlap, ( + f"{overlap} is exempt AND has a conflicting `organization_id`. Exempt " + f"says the table needs no isolation; the homonym says it cannot get " + f"any. Those are different problems and must not share an entry." + ) + + +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 113. A baseline whose stated size and real size + disagree is a baseline nobody trusts.""" + assert len(BASELINE_UNSCOPED) == 113 diff --git a/tests/unit/test_tenant_coverage.py b/tests/unit/test_tenant_coverage.py index 164b3650..78a1b8fe 100644 --- a/tests/unit/test_tenant_coverage.py +++ b/tests/unit/test_tenant_coverage.py @@ -18,6 +18,14 @@ the column, ``FORCE`` and a policy. Catches a migration that was written but never applied. +⚠️ **Both layers here match on the column NAME.** Neither asks what an existing +``organization_id`` references, and three CRM tables have one that points at +``crm_organizations`` — a customer company, not the tenant. That is why +``gen_tenant_migration.HOMONYM_BLOCKED`` exists, and why the partition +assertions ("every table is in exactly one bucket") live in +``test_tenancy_boundary.py``, which matches the foreign key's target. This file +does not make that check, despite what the paragraph above sounds like. + ⚠️ **The exemption map IS the security review.** Adding a name to ``gen_tenant_migration.EXEMPT`` takes a table out of tenant isolation. It is the only legitimate way out — and therefore the only way an illegitimate one gets in. 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/components/RelationsBlock.tsx b/workbench/control_plane/src/app/projects/components/RelationsBlock.tsx new file mode 100644 index 00000000..4cb9722d --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/RelationsBlock.tsx @@ -0,0 +1,286 @@ +"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 type { TaskRow } from "../lib/api"; +import { projectsApi } from "../lib/api"; +import { conflictLabel, conflicts } from "../lib/timeline"; +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; + /** + * WS-27t — the task this block belongs to, so the schedule warning can be + * computed here as well as on the timeline. Optional because the rule is a + * courtesy: without it the block still lists everything, it just cannot say + * that two of the dates disagree. + */ + task?: Pick; + /** Bumped by the panel when it adds a subtask, so this reloads. */ + refreshKey?: number; + onOpenTask: (taskId: string) => void; +} + +export function RelationsBlock({ + taskId, + task, + 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} + + {/* WS-27t / D-PM-12 — the warning half of "constrain, but only warn". + Nothing here reschedules anything, and the sentence says so: a user + who assumes the tool fixed it is worse off than one who was never + told. Same pure rule as the timeline's red arrow. */} + {task + ? data.blocked_by + .filter((blocker) => conflicts(blocker, task)) + .map((blocker) => ( +

+ + {conflictLabel(blocker.title)} +

+ )) + : 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/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/SearchPalette.tsx b/workbench/control_plane/src/app/projects/components/SearchPalette.tsx new file mode 100644 index 00000000..686851e2 --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/SearchPalette.tsx @@ -0,0 +1,216 @@ +"use client"; + +/** + * Projects · the search palette (WS-27r). + * + * `⌘K` from anywhere in Projects, type, arrow to a hit, Enter to open it. The + * last row of the parity backlog: `?q=` has been on the list endpoint since + * WS-27a with no way to reach it. + * + * **A palette rather than a search page**, because the question it answers is + * *"where is that task"* — asked while doing something else, usually about + * work in a project the person is not looking at. A page would make finding + * something a place you navigate TO, which is one navigation more than the + * problem has. + * + * All of the logic — the debounce, what to show while a request is in flight, + * which keystrokes belong to the palette, and how to ignore a stale response — + * is in `lib/search.ts` and tested there. Those are the rules that only break + * under real typing speed on a real connection, which is not a thing a + * component test reproduces. + */ + +import Icon from "@/components/Icon"; +import { Input } from "@/components/ui/Input"; +import { useEffect, useRef, useState } from "react"; + +import { projectsApi } from "../lib/api"; +import { + DEBOUNCE_MS, + type Hit, + highlight, + hitContext, + isCurrent, + moveSelection, + paletteKey, + paletteState, +} from "../lib/search"; + +interface Props { + open: boolean; + onClose: () => void; + onOpenTask: (taskId: string) => void; +} + +export function SearchPalette({ open, onClose, onOpenTask }: Props) { + const [query, setQuery] = useState(""); + const [hits, setHits] = useState(null); + const [truncated, setTruncated] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [cursor, setCursor] = useState(0); + const inputRef = useRef(null); + // Read inside the async callback so a response can be checked against what + // is in the box NOW, not against the value captured when it was sent. + const liveQuery = useRef(query); + liveQuery.current = query; + + // Reset on every open. A palette that reopens showing the last search is a + // palette you have to clear before you can use it. + useEffect(() => { + if (!open) return; + setQuery(""); + setHits(null); + setTruncated(false); + setError(null); + setCursor(0); + inputRef.current?.focus(); + }, [open]); + + useEffect(() => { + if (!open) return; + const term = query.trim(); + if (term.length < 2) { + setHits(null); + setLoading(false); + return; + } + setLoading(true); + const timer = setTimeout(async () => { + try { + const res = await projectsApi.search(term); + // The out-of-order guard: "par" and "parser" are two requests with no + // ordering guarantee, and a slow first one landing last would replace + // the right answers with stale ones. + if (!isCurrent(res.query, liveQuery.current)) return; + setHits(res.rows); + setTruncated(res.truncated); + setError(null); + setCursor(0); + } catch (err) { + if (isCurrent(term, liveQuery.current)) { + setError(String((err as Error).message)); + } + } finally { + if (isCurrent(term, liveQuery.current)) setLoading(false); + } + }, DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [query, open]); + + if (!open) return null; + + const view = paletteState({ query, loading, hits, truncated, error }); + const rows = view.kind === "results" ? view.hits : []; + + function activate(taskId: string) { + onOpenTask(taskId); + onClose(); + } + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Search tasks" + > +
+ + setQuery(e.target.value)} + placeholder="Search every project you can see…" + aria-label="Search tasks" + className="border-0 focus:border-0" + onKeyDown={(e) => { + const action = paletteKey(e); + if (!action) return; + // Claimed before the browser sees them: an unhandled ArrowUp + // moves the text caret to the start of the query, so the + // selection and the cursor would both move on one key. + e.preventDefault(); + if (action === "close") onClose(); + if (action === "down") setCursor((c) => moveSelection(c, 1, rows.length)); + if (action === "up") setCursor((c) => moveSelection(c, -1, rows.length)); + if (action === "open" && rows.length > 0) { + activate(rows[moveSelection(cursor, 0, rows.length)].id); + } + }} + /> + + esc + +
+ +
+ {view.kind === "idle" ? ( +

+ Type at least two characters. A number like{" "} + #42 finds that task. +

+ ) : null} + {view.kind === "searching" || view.kind === "typing" ? ( +

Searching…

+ ) : null} + {view.kind === "empty" ? ( +

+ Nothing matches “{query.trim()}”. +

+ ) : null} + {view.kind === "error" ? ( +

{view.message}

+ ) : null} + +
    + {rows.map((row, index) => ( +
  • + +
  • + ))} +
+ + {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/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/components/TaskPanel.tsx b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx index 7be9c6ef..b3e92e87 100644 --- a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx @@ -22,6 +22,8 @@ import { } from "../lib/api"; import { CustomFieldValues } from "./CustomFieldValues"; import { TagPicker } from "./TagPicker"; +import { RepeatEditor } from "./RepeatEditor"; +import { RelationsBlock } from "./RelationsBlock"; import { changeLabel } from "../lib/customFields"; import { assigneeLabel, @@ -52,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 { @@ -96,6 +104,7 @@ export function TaskPanel({ onTaskAdded, fields = [], tags = [], + onOpenTask, }: Props) { const [timeline, setTimeline] = useState([]); const [comment, setComment] = useState(""); @@ -103,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([]); @@ -236,6 +248,7 @@ export function TaskPanel({ setSubtask(""); await reload(); onTaskAdded?.(); + setRelationsKey((k) => k + 1); } catch (err) { setError(String((err as Error).message)); } finally { @@ -388,6 +401,18 @@ 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} +
Files diff --git a/workbench/control_plane/src/app/projects/components/TimelineView.tsx b/workbench/control_plane/src/app/projects/components/TimelineView.tsx new file mode 100644 index 00000000..c19858c5 --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/TimelineView.tsx @@ -0,0 +1,383 @@ +"use client"; + +/** + * Projects · the timeline (WS-27t). + * + * A sticky task column beside a scrolling chart of bars, with `blocks` + * dependencies drawn as arrows between them and created by dragging from one + * bar's handle to another. The geometry, the depth grouping and the conflict + * rule are all in `lib/timeline.ts` and tested there; this draws them. + * + * **The layout is Paca's** (`roadmap-view.tsx`, Apache-2.0): sticky left + * column, fixed pixels-per-day, month header cells, a today line, and an + * undated task listed on the left with no bar. **The wiring is not** — Paca's + * roadmap draws no dependency arrows and is entirely read-only, so from here on + * the reference was Jira and ClickUp and the interaction is ours. + * + * **An arrow warns; it never reschedules (D-PM-12).** A dependency whose + * blocker finishes after the blocked task starts is drawn in the danger tone + * and says so on hover. Nothing is written. The alternative — dragging + * dependents forward, which is what Jira does — was rejected because it + * contradicts WS-27p's "derived and shown, never enforced" and turns one drag + * into a cascade of writes nobody asked for. + */ + +import Icon from "@/components/Icon"; +import { TaskMeta } from "@/components/TaskMeta"; +import Button from "@/components/ui/Button"; +import { useMemo, useState } from "react"; + +import type { TaskRow } from "../lib/api"; +import { cardChips } from "../lib/card"; +import { dayKey, shiftDay } from "../lib/calendar"; +import { + type Edge, + PX_PER_DAY, + ROW_H, + bar, + canLink, + conflictLabel, + conflicts, + dayPx, + edgePath, + monthCells, + timelineRange, + timelineRows, +} from "../lib/timeline"; + +const LEFT_COL = 260; + +interface Props { + tasks: TaskRow[]; + links: Edge[]; + undated: number; + truncated: boolean; + today?: string; + onSelect: (task: TaskRow) => void; + onLink: (blockerId: string, blockedId: string) => void; + onRefuse: (reason: string) => void; +} + +export function TimelineView({ + tasks, + links, + undated, + truncated, + today, + onSelect, + onLink, + onRefuse, +}: Props) { + const [expanded, setExpanded] = useState>(new Set()); + const [dragging, setDragging] = useState(null); + const todayKey = today ?? dayKey(new Date()); + + const rows = useMemo(() => timelineRows(tasks), [tasks]); + const range = useMemo(() => timelineRange(rows, todayKey), [rows, todayKey]); + + // One flat list of drawn lines — parents, then their children when expanded — + // so a row's index IS its y. Arrows are positioned from that index, and a + // second traversal to find it would be a second chance to disagree. + const drawn = useMemo(() => { + const out: { + task: TaskRow; + children: TaskRow[]; + depth: number; + hasKids: boolean; + }[] = []; + for (const row of rows) { + out.push({ + task: row.task, children: row.children, depth: 0, + hasKids: row.children.length > 0, + }); + if (expanded.has(row.task.id)) { + for (const kid of row.children) { + out.push({ task: kid, children: [], depth: 1, hasKids: false }); + } + } + } + return out; + }, [rows, expanded]); + + const indexById = useMemo( + () => new Map(drawn.map((row, i) => [row.task.id, i])), + [drawn], + ); + const barById = useMemo( + () => new Map(drawn.map((row) => [row.task.id, bar(row.task, row.children, range)])), + [drawn, range], + ); + const taskById = useMemo( + () => new Map(tasks.map((t) => [t.id, t])), + [tasks], + ); + + const months = monthCells(range); + const todayPx = todayKey >= range.from && todayKey <= range.to + ? dayPx(todayKey, range) : null; + const height = Math.max(drawn.length * ROW_H, ROW_H); + + function drop(blockedId: string) { + const blockerId = dragging; + setDragging(null); + if (!blockerId) return; + const verdict = canLink(blockerId, blockedId, links); + if (!verdict.ok) { + onRefuse(verdict.reason); + return; + } + onLink(blockerId, blockedId); + } + + if (drawn.length === 0) { + return ( +

+ No tasks to display. + {undated > 0 ? ` ${undated} have no start or due date.` : ""} +

+ ); + } + + return ( +
+
+ Timeline + + Drag the handle on a bar's right edge onto another bar to say it + blocks that one. + + {undated > 0 ? ( + + {undated} unscheduled + + ) : null} + {truncated ? ( + + Too many tasks to show them all — narrow the filters. + + ) : null} +
+ +
+
+ {/* Task column — sticky, so the names stay while the chart scrolls. */} +
+
+ Task +
+ {drawn.map((row) => ( +
+ {row.hasKids ? ( + +
+ ))} +
+ + {/* 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 8077cdcf..e8c7dbf9 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/*. * @@ -28,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[]; @@ -41,6 +51,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 { @@ -172,6 +190,52 @@ 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[]; + /** + * 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; + }>(`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) => @@ -201,6 +265,45 @@ 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`), + + /** 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/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/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/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, + }; +} 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..ab3d829e --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/relations.ts @@ -0,0 +1,140 @@ +/** + * 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; + /** + * 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 { + id: string; + title: string; + task_number?: number | null; + status_id: string; + status_name?: string | null; + category?: string | null; + completed_at?: string | null; + start_date?: string | null; + due_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/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/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 aa2eb08f..04ac7413 100644 --- a/workbench/control_plane/src/app/projects/page.tsx +++ b/workbench/control_plane/src/app/projects/page.tsx @@ -34,10 +34,16 @@ 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 { 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, type Filters, @@ -59,7 +65,16 @@ import { import { fetchAccess } from "@/lib/access"; import { filterByCenter, flatten } from "./lib/tree"; -type ViewMode = "board" | "list"; +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[], + links: [] as Edge[], + undated: 0, + truncated: false, +}; function ProjectsWorkspace() { const searchParams = useSearchParams(); @@ -117,11 +132,34 @@ 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. + // 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); + const [picked, setPicked] = useState>(new Set()); const [anchor, setAnchor] = useState(null); 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. @@ -209,6 +247,48 @@ 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, + // 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, + }); + } 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, mode]); + + useEffect(() => { + // 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(() => { if (!selected) { setFields([]); @@ -463,6 +543,55 @@ 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. + */ + /** + * 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, + 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, @@ -610,10 +739,19 @@ function ProjectsWorkspace() { Tags ) : null} +
- {(["board", "list"] as ViewMode[]).map((m) => ( + {(["board", "list", "calendar", "timeline"] as ViewMode[]).map((m) => (