diff --git a/AGENTS.md b/AGENTS.md index c32de348d..28bea6d9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ Remove stale or contradictory text immediately. Organisation: Fracktal Works Project: CommandCenter v2 -- Headless, self-mutating agent orchestration platform Runtime: MAF (Microsoft Agent Framework) native, plus the GitHub Copilot SDK as a second runtime for interactive coworker chat + the self-mutation sandbox. No LangGraph. No deepagents. No n8n. -Last updated: 2026-07-13 +Last updated: 2026-08-09 ## Purpose @@ -93,14 +93,32 @@ Copilot SDK sandboxes. 1. No in-app agent/skill *code* editing -- all code authoring is VS Code + Git. The Workflows app (`/workflows`) is the sanctioned exception-by-design: workflows are DB-persisted configuration orchestrating code-authored agents, compiled to MAF Workflows (ADR-028; spec ai-company-brain/specs/workflows_app.md) -- not generated agent code, not a second runtime 2. No credentials in agent or skill repos -- Integration Registry holds all secrets 3. Self-mutation max_mutation_attempts = 1 per failure event - - ⚠️ **DEV-ONLY / must be replaced before production:** native MAF agents (local_path, no own remote) currently land approved self-mutations by opening a PR against THIS Command Center monorepo. This is fine only while all agents are first-party and Command Center is WIP. It MUST be swapped for a tenant-isolated mechanism before any multi-tenant/customer deployment — third parties must never push to the shared monorepo. See `docs/DESIGN_LIMITATION_native_maf_mutation.md`. + - ⚠️ **DEV-ONLY / must be replaced before production:** native MAF agents (local_path, no own remote) currently land approved self-mutations by opening a PR against THIS Command Center monorepo. This is fine only while all agents are first-party and Command Center is WIP. It MUST be swapped for a tenant-isolated mechanism before any multi-tenant/customer deployment — third parties must never push to the shared monorepo. See `docs/DESIGN_LIMITATION_native_maf_mutation.md`. **This is now ticketed as `saas_multitenancy.md` MT-0b (WS-29) and is a HARD BLOCKER before customer #2** — the cheapest sufficient fix is a config gate defaulting to disabled, not a redesign. 4. No autonomous writes to source systems until Action Broker is live 5. Git is the single source of truth for all agent artefacts 6. MAF is the PRIMARY native agent runtime. The Copilot SDK is the supported second runtime for interactive coworker chat (Tier 1.5, /copilot/chat, BYOK-routed through the gateway) and the self-mutation sandbox -- not a general execution path for event-driven specialist agents 7. No Theia / browser IDE 8. Source systems are authoritative -- CommandCenter is a read-mostly mirror 9. New event-driven / specialist-agent execution features default to MAF paths; the Copilot-SDK runtime is reserved for interactive chat + mutation (both gateway-routed), not new autonomous execution entrypoints -10. **All gateway endpoints require auth, by construction rather than by opting in.** `require_authenticated` is attached app-wide at the `FastAPI(dependencies=[…])` level, so a route added tomorrow is covered without anyone remembering; `PUBLIC_ROUTES` is the exemption list and every entry authenticates itself another way. **Before building or modifying ANY app, read `ai-company-brain/specs/user_management_contract.md`** — the ten binding rules for identity, membership and authorization, each one learned by breaking it. In particular: never navigate the browser directly at the gateway (it carries no credentials), never add a route to `PUBLIC_ROUTES` to make it reachable, and never take the acting identity from a query parameter or request body. +10. **All gateway endpoints require auth, by construction rather than by opting in.** `require_authenticated` is attached app-wide at the `FastAPI(dependencies=[…])` level, so a route added tomorrow is covered without anyone remembering; `PUBLIC_ROUTES` is the exemption list and every entry authenticates itself another way. **Before building or modifying ANY app, read `ai-company-brain/specs/user_management_contract.md`** — the ten binding rules for identity, membership and authorization, each one learned by breaking it. In particular: never navigate the browser directly at the gateway (it carries no credentials), never add a route to `PUBLIC_ROUTES` to make it reachable, and never take the acting identity from a query parameter or request body — **nor the acting TENANT, which is R11, added 2026-08-08 with D15; the tenant comes from the authenticated session or a tenant-scoped API key and from nowhere else.** The contract carries **eleven** rules, not ten. +11. **Multi-tenancy is `organization_id` + Postgres RLS, and it is NOT built yet.** + The tenant boundary was re-taken on 2026-08-08 (**D15**, board **WS-29**, spec + `ai-company-brain/specs/saas_multitenancy.md`): a tenant is a **row** isolated by + `FORCE ROW LEVEL SECURITY` bound at the `get_db()` seam; a deployment is a + *placement*, not a boundary. This **supersedes `tenancy_and_visibility.md` §1 and §6** + (one-deployment-per-tenant) — **§2–§5 of that document, the private → Center → org + visibility ladder, are unchanged and still binding.** Before building anything that + persists tenant data: read `saas_multitenancy.md` §1 and §11, and + `saas_multitenancy_implementation.md` for the shapes. Two rules bind today, ahead of + the build: **never introduce a second scoping doctrine** (tenant isolation is + `organization_id`; visibility inside a tenant stays `email | group: | org`), and + **never give an agent a raw-SQL tool or a database connection** — §0.9.3 makes that a + condition on the whole tenancy decision, not a nicety. Board rule **R5** + (`ai-company-brain/work_plan.md` §1, owner-directed 2026-08-09) binds every PR + tenant-ready by construction while WS-29 is in flight: new persisted tables satisfy + the tenant-coverage gate (or are exempted with a reason), no new database-connection + or Redis sites outside the seam/wrapper, and session acquisition stays on the seam + idiom so the H2 conversion remains mechanical. ## Global Conventions diff --git a/COMPETITIVE_COMPARISON.md b/COMPETITIVE_COMPARISON.md index cd2cda02f..db6583cda 100644 --- a/COMPETITIVE_COMPARISON.md +++ b/COMPETITIVE_COMPARISON.md @@ -84,7 +84,7 @@ Legend: ✅ real / mature · ◑ partial or default-off · ⚠️ designed-but-n | Cost tracking | ◑ was silently $0 (now reports unknown) | ✅ per-turn cost + `/usage`/`/insights` | ◑ | | Self-mutation / self-heal | ◑ Copilot Docker sandbox patches broken repos (partial reach) | ◑ skills self-heal during use | ✖ | | Audit log | ✅ append-only (but sync on async loop) | ◑ structured logs | ◑ | -| Multi-tenancy / org RBAC | ⚠️ designed; auth "never rejects" today | ✖ single-user by design | ✖ single-user by design | +| Multi-tenancy / org RBAC | ⚠️ in build: default-deny auth SHIPPED (BO-2 closed); row-level multi-tenancy in flight as WS-29 (D15 — organization_id + RLS; H1 scratch-verified 2026-08-09) | ✖ single-user by design | ✖ single-user by design | --- diff --git a/FOUNDATION_BUILDOUT_CHECKLIST.md b/FOUNDATION_BUILDOUT_CHECKLIST.md index 80d190c7d..62ecf1e27 100644 --- a/FOUNDATION_BUILDOUT_CHECKLIST.md +++ b/FOUNDATION_BUILDOUT_CHECKLIST.md @@ -2,8 +2,8 @@ **Date:** 2026-07-11 · **Deploy status updated:** 2026-07-13 · **Competitive refs added:** 2026-07-13 **§BO‑20 rewritten and verified against code: 2026-08-02** (WS‑4 audit remediation). Verified this pass: the ingestion package contents (no `worker.py`), the ClickUp → `event_hooks.emit_event` → `workflows.triggers.dispatch_event` → `start_run` fan-out, the Gmail/Zoho `TODO` stubs, the repo-wide absence of `xreadgroup`/`xgroup`/`xack`, the four checked-in systemd units, the already-provisioned Redis compose service, `uv.lock`'s lack of any job-queue library, and the gateway lifespan's supervised loops (five wired, **all five** stopped on shutdown, four actually started in the default config — WhatsApp enrichment is flag-gated off). §BO‑20 now carries acceptance criteria, verification commands, gate labels, and one named owner decision (§BO‑20.0). **That decision was answered on 2026-08-02 — `BO‑20 = Option A (in‑process)`** — so nothing in §BO‑20 is blocked on a decision any more. **BO‑20f and BO‑20a are BUILT (both 2026-08-02) and BO‑20b slice 1 (`emit_event` strict mode) 2026-08-03, moving §BO‑20 ☐ → ◑; BO‑20b slice 2 and BO‑20c–e are open and dispatchable.** ⚠️ **Slice 1 is necessary but NOT sufficient** (adversarial review 2026-08-03): the only sink production registers, `workflows.triggers.dispatch_event`, swallows every exception, so `raise_on_error=True` is a no‑op on the real registry — **slice 2's scope grew** to include a matching strict path in `dispatch_event`, and the §BO‑20 non‑goal "Not a change to `dispatch_event`" is struck and qualified. Two claims in the stamp above were made false by BO‑20a and are corrected in place below: `xreadgroup`/`xgroup`/`xack` now exist (in `ingestion/consumer.py` only), and the lifespan's supervised loops are **six** wired / **all six** stopped on shutdown (how many actually *start* is data‑dependent — see §BO‑20 "What is true today" §7, which counts it honestly; the new ingestion consumer joins WhatsApp enrichment as flag‑gated **off**, so it starts nowhere today). `INGESTION_CONSUMER` is registered in `work_plan.md` §6. **Other sections carry no such stamp** — BO‑1/BO‑19 were stamped by the 2026-08-01 doc-truth pass; the rest are as-authored. -**§BO‑10 / §BO‑13 / §BO‑14 / §BO‑15 / §BO‑19 re-measured and corrected, §BO‑23 added, and the "can we go app by app?" verdict block added: 2026-08-03** (WS‑0 truth pass, second commit on PR #344). Verified this pass by measuring, not by reading the prior doc: the **12** `create_async_engine` call sites across 10 modules and the fact that the 8 cached singletons are never disposed (BO‑10 said "three+"); `executor.py` at **5,010** lines and `run_agent_stream` at **~1,942** (BO‑13 claimed 4,069 / ~1,600 — both July numbers the file has since grown past); `permission_policy.decide`'s two hard‑veto return paths and `install_dependency`'s `destructive: True` annotation (BO‑14 claimed the gate "can never deny" and the registry was "empty" — **both false**); `model_limits.py` as the retired‑five‑sources context‑window SoT versus `_TIER_DEFAULTS` + three still‑on‑disk config files (BO‑15 is **half** closed, not closed and not untouched); the root `AGENTS.md`'s deleted version table and `infra/AGENTS.md`'s corrected proxy/Langfuse lines (BO‑19 → ✅); `dump_schema.sh`'s `--schema-only`, `apply_migrations.sh`'s 140‑file `ON_ERROR_STOP=1` replay, the absence of any WAL/pgbackrest/wal‑g config, and `deploy/hostinger/README.md:115` (→ **BO‑23**); and, live against GitHub, `branches/main/protection` → 404 with `rulesets` → `[]`. **Two audit claims handed to this pass were wrong and are not transcribed:** BO‑14/BO‑15 were described as fully closed (BO‑14's defects are closed but its *residual* is real and different; BO‑15 is half). Tenancy/visibility architecture from the same day lives in `ai-company-brain/specs/tenancy_and_visibility.md`, not here. -**Companion to:** `FOUNDATION_AUDIT_REPORT.md` · handoff details in `FOUNDATION_CONTINUATION.md` (see its "LATEST STATUS" block) · competitive learnings (proven reference implementations from Hermes Agent & OpenClaw) in `ai-company-brain/specs/competitive_hardening_2026-07.md` (`CH-*`) and `COMPETITIVE_COMPARISON.md` · tenancy + visibility architecture of record in `ai-company-brain/specs/tenancy_and_visibility.md`. +**§BO‑10 / §BO‑13 / §BO‑14 / §BO‑15 / §BO‑19 re-measured and corrected, §BO‑23 added, and the "can we go app by app?" verdict block added: 2026-08-03** (WS‑0 truth pass, second commit on PR #344). Verified this pass by measuring, not by reading the prior doc: the **12** `create_async_engine` call sites across 10 modules and the fact that the 8 cached singletons are never disposed (BO‑10 said "three+"); `executor.py` at **5,010** lines and `run_agent_stream` at **~1,942** (BO‑13 claimed 4,069 / ~1,600 — both July numbers the file has since grown past); `permission_policy.decide`'s two hard‑veto return paths and `install_dependency`'s `destructive: True` annotation (BO‑14 claimed the gate "can never deny" and the registry was "empty" — **both false**); `model_limits.py` as the retired‑five‑sources context‑window SoT versus `_TIER_DEFAULTS` + three still‑on‑disk config files (BO‑15 is **half** closed, not closed and not untouched); the root `AGENTS.md`'s deleted version table and `infra/AGENTS.md`'s corrected proxy/Langfuse lines (BO‑19 → ✅); `dump_schema.sh`'s `--schema-only`, `apply_migrations.sh`'s 140‑file `ON_ERROR_STOP=1` replay, the absence of any WAL/pgbackrest/wal‑g config, and `deploy/hostinger/README.md:115` (→ **BO‑23**); and, live against GitHub, `branches/main/protection` → 404 with `rulesets` → `[]`. **Two audit claims handed to this pass were wrong and are not transcribed:** BO‑14/BO‑15 were described as fully closed (BO‑14's defects are closed but its *residual* is real and different; BO‑15 is half). Tenancy/visibility architecture from the same day lives in `ai-company-brain/specs/tenancy_and_visibility.md`, not here. *(Amended 2026-08-09: the tenancy half of that architecture now lives in `ai-company-brain/specs/saas_multitenancy.md` — D15, 2026-08-08; visibility stays with `tenancy_and_visibility.md` §2–§5.)* +**Companion to:** `FOUNDATION_AUDIT_REPORT.md` · handoff details in `FOUNDATION_CONTINUATION.md` (see its "LATEST STATUS" block) · competitive learnings (proven reference implementations from Hermes Agent & OpenClaw) in `ai-company-brain/specs/competitive_hardening_2026-07.md` (`CH-*`) and `COMPETITIVE_COMPARISON.md` · tenancy architecture of record in `ai-company-brain/specs/saas_multitenancy.md` (D15, 2026-08-08) · visibility architecture of record in `ai-company-brain/specs/tenancy_and_visibility.md` §2–§5. > **🚀 Deploy status:** read live deploy state from `gh run list` and `git log origin/main` — not from this doc. Next recommended P0: **BO‑8** (secret rotation + history purge — owner‑gated); BO‑1's approval loop has since shipped (see §BO‑1). > **Update 2026-08-01 (doc-truth pass):** the previous pinned‑commit claim here (`origin/main = ccccdc8`, unpushed `1684e1a`) was a 2026‑07‑13 snapshot and went stale; this doc no longer tracks deploy state. @@ -102,7 +102,7 @@ restore against real production data, which no test can stand in for. #### BO‑1c — email handlers *(AGENT‑SAFE to build, but BLOCKED on the decision below · 1–2 medium PRs)* Confirmed real remaining work: there is **zero** `action_broker` wiring anywhere under `apps/services/email_ingestion/` — every outward email write bypasses the broker today. But the ticket is not dispatchable until §BO‑1 names *which* verbs are broker actions, because the provider base class (`email_ingestion/providers/base.py`) exposes **14** mutating verbs: `send_message` (`:264`), `modify_message` (`:289`), `trash_message` (`:299`), `apply_flags` (`:307`), `move_to_folder` (`:322`), `bulk_apply` (`:339`), `create_folder` (`:387`), `create_filter` (`:398`), `delete_filter` (`:416`), `set_labels` (`:446`), `set_label_color` (`:484`), `create_draft` (`:492`), `update_draft` (`:521`), `send_draft` (`:551`). Brokering all 14 would put a human approval in front of every label click. -**DECISION (agent‑proposed, owner may overrule) — broker the destructive/outward set only: `send_message`, `send_draft`, `trash_message`, `delete_filter`.** Rationale: these are the four that either leave the system (a recipient sees it) or destroy state a user cannot trivially restore. **Explicit non‑goal:** label, flag, folder‑move, draft‑create/update and filter‑create operations are **not** brokered — they are reversible, in‑mailbox, high‑frequency, and Command Center is an internal Fracktal tool used by trusted colleagues, so a per‑click approval would be pure friction with no trust gain. `bulk_apply` is a fan‑out over `move_to_folder`/`trash_message` (`base.py:339-386`), so it inherits the gate only through `trash_message`. +**DECISION (agent‑proposed, owner may overrule) — broker the destructive/outward set only: `send_message`, `send_draft`, `trash_message`, `delete_filter`.** Rationale: these are the four that either leave the system (a recipient sees it) or destroy state a user cannot trivially restore. **Explicit non‑goal:** label, flag, folder‑move, draft‑create/update and filter‑create operations are **not** brokered — they are reversible, in‑mailbox, high‑frequency, and Command Center is an internal Fracktal tool used by trusted colleagues, so a per‑click approval would be pure friction with no trust gain. *(Premise dated 2026-08-09: true until the first external tenant — WS-29/D15 retires it; the `ACTION_BROKER_ENFORCE` posture must be re-decided before customer #1.)* `bulk_apply` is a fan‑out over `move_to_folder`/`trash_message` (`base.py:339-386`), so it inherits the gate only through `trash_message`. **Done when (once the decision above is confirmed or overruled):** 1. The four chosen verbs route through a gate of the same shape as `BaseTaskProvider._broker_gate` — audit + auto‑apply by default, queue only under `ACTION_BROKER_ENFORCE`, and a broker‑layer error never blocks a user‑approved write. @@ -120,7 +120,7 @@ Measured on this branch, 2026-08-03: **`32 passed in 1.95s`** — hermetic, no l - **Why needed:** It is non‑negotiable #4 ("no autonomous writes to source systems until the Action Broker is live") and the single control point for HITL over all outward writes. The chokepoint now exists and is audited for ClickUp tasks, WhatsApp broadcast, workflow resume and app publish‑review; it does **not** yet cover email, and two of its own gated ClickUp actions cannot execute after approval (BO‑1a). - **Dependencies:** `pending_actions` (exists, `66_pending_actions.sql`); `acb_audit`; the Control Plane approval inbox (exists, `routes/actions.py`); BO‑2 (authenticated approvals — ✅, the routes are behind `require_internal_auth`). -- **Note:** With enforcement OFF (the default) writes auto‑apply and are audited, so #4 is satisfied by audit-and-chokepoint rather than by human approval. That is the deliberate posture for an internal tool; flipping `ACTION_BROKER_ENFORCE` on is the OWNER‑GATE that turns it into a true HITL gate, and it must not be flipped before BO‑1a and BO‑1b land. +- **Note:** With enforcement OFF (the default) writes auto‑apply and are audited, so #4 is satisfied by audit-and-chokepoint rather than by human approval. That is the deliberate posture for an internal tool; flipping `ACTION_BROKER_ENFORCE` on is the OWNER‑GATE that turns it into a true HITL gate, and it must not be flipped before BO‑1a and BO‑1b land. *(Premise dated 2026-08-09: true until the first external tenant — WS-29/D15 retires it; the `ACTION_BROKER_ENFORCE` posture must be re-decided before customer #1.)* - **Competitive ref (CH‑2):** Hermes Agent routes every risky action through a **single fail‑closed approval gate** — the pattern to copy is "one choke point that a write physically cannot bypass," which is exactly what `execute()`‑only‑writes enforces. See `specs/competitive_hardening_2026-07.md`. ### BO‑2 — Enforceable authentication + authorization *(P0)* ✅ @@ -1750,3 +1750,38 @@ non‑blocking style backlog. **Competitive‑informed items** (proven reference implementations from Hermes Agent / OpenClaw — full mapping in `ai-company-brain/specs/competitive_hardening_2026-07.md`): CH‑1→BO‑7/BO‑14, CH‑2→BO‑1, CH‑3→BO‑20, CH‑4→WBS 3.3, CH‑5→BO‑12, CH‑6→BO‑21, CH‑7→Phase‑5 Annealer, CH‑8→BO‑5. These do not change the sequencing above — they attach a "what good looks like" reference to items we already have, plus the two new items (BO‑20/BO‑21) the comparison surfaced. The review pass already delivered F1–F6 (see report §6), which knock out the open LLM proxy, the on‑disk secret/junk exposure, the false‑$0 cost bug, the migration‑number collision, and the worst doc drift — clearing the cheapest Critical/High items so the P0 sprint can focus on the architectural ones. + +--- + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-1 — **Action Broker truth + completion** (BO-1) + +**State cell (as of the move):** 🟢 + +**Narrative (verbatim):** Broker loop LIVE and writing (inbox, `/actions`, ClickUp + WhatsApp + workflow + app-publish handlers). **Handlers register at SIX sites, not the three this row claimed** (five measured 2026-08-03, plus the CRM's on 2026-08-05): `gateway/main.py` registers the four ClickUp task actions, `workflow.resume_run`, and — new — the three `crm.zoho_*` sync pushes; `routes/whatsapp/scheduler_hooks.py` registers `whatsapp.broadcast`; and `routes/apps/tools.py` registers two app-tool actions **at module import**, not startup. ~~"Remaining: **Zoho** handlers"~~ **struck as BO-1 work and it stays struck — the Zoho handlers now exist and are WS-26b's, not this workstream's.** `apps/services/ingestion/ingestion/sources/zoho/client.py` **stays** read-only — as of 2026-08-07 it is TEN read functions (WS-26b added `list_leads` and the deleted-records reader `list_deleted`; WS-26f added `list_deal_layouts` and `list_deal_pipelines`), all `GET`, and its one `POST` is still the OAuth token refresh. ⚠️ The claim "still all `GET /crm/v2/*`" is no longer true and must not be restored: WS-26f's two settings readers are the one deliberate exception (`settings/pipeline` does not exist on v2), version named once as `client.SETTINGS_API_VERSION` with a refusal reported rather than retried downward. Line numbers deliberately dropped: they drifted the first time anybody touched the file. ~~"There is no Zoho write path anywhere in the repo to route through the broker"~~ **corrected 2026-08-05 — there is one now, and it is NOT BO-1's.** WS-26b built `apps/services/ingestion/ingestion/sources/zoho/writer.py` (create/update/upsert/delete) on branch `ws-26b-zoho-sync`, per spec `crm_app.md` D-CRM-7/D-CRM-8. It has exactly ONE caller — `gateway/routes/crm/sync_zoho.py::execute_push`, grep-asserted in `tests/unit/test_crm_zoho_sync.py` — and every push crosses `routes/crm/broker_handlers.py::broker_gate` first. Its three actions (`crm.zoho_create`/`_update`/`_delete`) are registered from `main.py` alongside the ClickUp set, so the handler-registration count is now SIX sites, not five, and **all three CRM actions have handlers** (BO-1a's gap is ClickUp-only). Nothing has run against the tenant: `CRM_ZOHO_SYNC` ships OFF and enabling it is OWNER-GATE §6. The whole write path retires with WS-26e. ~~"verify vs live DB"~~ → **OWNER-GATE, and the "already done 2026-07-13" claim is UNSUPPORTED** — `FOUNDATION_CONTINUATION.md:145` records it outstanding and nothing since records it executed; no agent may claim it done or reach prod to do it, and it is not an acceptance criterion for anything below. **Three new tickets in §BO-1, all AGENT-SAFE, one PR each — the first two are flip-blockers, both new findings:** **BO-1a** — `providers.py` routes **six** ClickUp action names through `_broker_gate` but `broker_handlers._WRITERS` registers **four**, and the two missing are the two *irreversible* ones (`clickup.delete_task` `:551`, `clickup.archive_task` `:575`); under enforcement, approving one falls into `broker.execute()`'s no-handler branch (`broker.py:155-166`) and the row is marked **`failed`**. **BO-1b** — `_broker_gate` returns `{"pending": True, …, "provider_task_id": ""}` (`providers.py:171-172`) and `items._push_pending_item` ignores the marker, writing `sync_state='synced'` with an empty `provider_task_id` — under enforcement the user sees a green "synced" task that exists in no workspace. **BO-1c** — email handlers (zero `action_broker` wiring under `email_ingestion/`), buildable but blocked on §BO-1's recorded decision naming which of the base class's **14** mutating verbs are broker actions. **OWNER-GATE:** flipping `ACTION_BROKER_ENFORCE` on — **not until BO-1a and BO-1b are both in**, for the two reasons above. + +**Corrections applied 2026-08-09:** +- Current as moved; the Zoho sync loop is RUNNING (owner-enabled 2026-08-06) — any "`CRM_ZOHO_SYNC` ships OFF / never run" phrasing inside is historical. + +### WS-4 — **Event-bus consumer + durable queue** (BO-20) + +**State cell (as of the move):** 🟢 a+f built · b slice 1 built · b slice 2 + c–e open + +**Narrative (verbatim):** **§BO-20.0 IS ANSWERED — `BO-20 = Option A (in-process)`, owner, 2026-08-02.** Nothing in this row is blocked on a decision any more; the recorded rejection of Option B (a separate `python -m ingestion.worker`: needs a systemd unit no agent can deploy, and a separate process starts with an empty `event_hooks._SINKS`, so it would `XREADGROUP`, `XACK` and dispatch to nothing) is kept in §BO-20.0 as the reasoning, not deleted. **BO-20a BUILT 2026-08-02, pending review:** `apps/services/ingestion/ingestion/consumer.py` — `XGROUP CREATE cc-ingest $ MKSTREAM` on all three streams (`$` = tail, so the ~10k buffered entries per stream are skipped, not replayed into real workflow runs), a supervised `XREADGROUP` drain loop (`_GROUP="cc-ingest"`, `_BLOCK_MS=5_000`, `_READ_COUNT=8`, per-worker consumer name `gw--` because BO-20b's `XAUTOCLAIM` identifies a dead worker by it) decoding `{event_type, JSON data}` into `event_hooks.emit_event(source, event_type, dict)` and `XACK`ing, a long-lived pooled `redis.asyncio` client per `acb_common/activity.py:66-76`, `start/stop_ingestion_consumer()` + `consumer_status()` in the gateway lifespan (start `main.py:307`, stop `:364` — **unconditional**, like `stop_whatsapp_enrichment`), and the **§BO-20 Q1 cutover in all three receivers**: flag ON ⇒ enqueue-only, flag OFF ⇒ **dispatch-identical** to before (not byte-identical — each receiver now also does one function-body import + one `os.environ` read per request). Packaging defect closed: `ingestion` is now a declared gateway dependency (`pyproject.toml` + `uv.lock`), not an inheritance from the root workspace umbrella. Pinned by `tests/unit/test_ingestion_consumer.py` (41 tests; **77 passed** across the four-file fence — 41 + 10 + 22 + 4, the other three unmodified), no Redis/DB/network. **Adversarial review 2026-08-03 → APPROVE, no P0/P1;** the four P2s were repaired in-branch: a `asyncio.timeout(_DISPATCH_TIMEOUT_SECS=30.0)` around `emit_event` (one serial loop drains all three streams, so an unbounded await turned a per-event hang into a **bus-wide, silent** stall — strictly worse than the pre-cutover `BackgroundTasks` hang it replaces), a test pinning the lifespan start/stop wiring itself, one shared ordered timeline so criterion A can tell ack-after-dispatch from ack-before-dispatch (the line BO-20b edits), and `assert task.cancelled()` instead of the weaker `task.done()` — **the reviewer's last item was half a fix**: cancelling a task that has never been stepped makes asyncio raise `CancelledError` above the loop's `try`, so `task.cancelled()` passes against a swallowing loop too; the test now waits for the loop to reach its first read before stopping, and was verified red against a deliberately-swallowing `_consumer_loop`. ⚠️ **Ships OFF and is inert in every environment:** `INGESTION_CONSUMER` is unset everywhere, so the loop never starts and the receivers still emit inline. **OWNER-GATE:** flipping `INGESTION_CONSUMER=1` (registered in §6) — it is not just "start a loop": the same flag cuts the three provider receivers over to enqueue-only, so **Redis down = provider events dropped** rather than dispatched inline. That drop is now logged loudly (`.queue.dropped`, warning) instead of being silent, and must not be "fixed" by re-emitting inline. **Interim semantics, deliberate:** BO-20a acks after dispatch regardless of outcome — honest `XACK` + retry + DLQ is **BO-20b**, now split in two. **BO-20b slice 1 BUILT 2026-08-03:** `event_hooks.emit_event` gained a **keyword-only** `raise_on_error: bool = False` — the strict mode the consumer needs to observe a failure at all, since `emit_event` swallowed every sink exception by design and BO-20b's retry logic is dead code without it. Default unchanged (swallow, log `event_hooks.sink_failed`, run the next sink — a webhook must never 5xx); `raise_on_error=True` propagates the **first** sink exception and skips the remaining sinks. Keyword-only so the three receivers' three-positional-arg `add_task(emit_event, source, event_type, payload)` can never reach it, and the default is pinned as the literal `False` via `inspect.signature` so a later PR cannot flip provider-facing behaviour silently. `consumer.py` is **untouched** — it still acks regardless of outcome. Three new tests (`tests/unit/test_ingestion_consumer.py` §J), four-file fence **80 passed** (44 + 10 + 22 + 4, the last three unmodified); both mutants (drop the `raise`, flip the default) verified red. **BO-20b slice 2 is open, and its SCOPE GREW on 2026-08-03** (adversarial review, repair round 1): slice 1 is *necessary but not sufficient*. `main.py:1074` registers exactly **one** sink, `workflows.triggers.dispatch_event`, and its whole body sits inside a `try/except Exception` that logs `workflows.event_dispatch_failed` and returns `[]` (`triggers.py:45-46`, `:90-104`) — so `raise_on_error=True` is a **no-op on the real registry**: slice 2 would have called it, `dispatch_event` would have swallowed, `emit_event` would have returned normally, the loop would have `XACK`ed, and the event would be **gone** with no retry, no PEL entry and no DLQ row — with every test green, because the suite registers a *raising fake* sink, a shape production does not have. Slice 2 therefore also owns a keyword-only strict path in `dispatch_event` (`triggers.py` joins its Files list; `tests/unit/test_workflows_slice2.py` joins its regression fence, 80 → 90 passed), with the failure boundary prescribed in §BO-20b: **propagate** the `_get_db`/trigger-query failure and `RunRejected` (raised at `service.py:193-196` *before* the run row and the task, so nothing ran), **never** the per-run execution failures (fire-and-forget via `create_task` at `service.py:226` — re-delivering would start a *second* run of the same workflow on the same payload), and raise **after** the row loop so a partial dispatch is not made worse. §BO-20's non-goal "Not a change to `dispatch_event`" is **struck and qualified** accordingly — that is a third `DECISION (agent-proposed, owner may overrule)` on this row; the rejected alternative was to leave `dispatch_event` untouched and accept that the consumer cannot distinguish "dispatched" from "swallowed", i.e. BO-20b cannot deliver its guarantee. Slice 2 also carries two `DECISION (agent-proposed, owner may overrule)` entries recorded in §BO-20b, because the ticket as written was *satisfiable while doing nothing*: (i) **retry is PEL-and-reclaim, not an in-loop `asyncio.sleep`** — the prescribed `_backoff` schedule (1,2,4,8,16 s) was dominated by the same section's `_RECLAIM_MIN_IDLE_MS = 60_000`, so the two constants could not both be true; `_backoff` is **struck** (it was also unpinned at its *call site*, so it could be defined, satisfy all four asserted properties, never be called, and close green), a `_RECLAIM_EVERY_SECS = 30.0` periodic cadence is prescribed with a done-when that the periodic pass **exists**, and the attempt counter is `XPENDING`'s `times_delivered` (an in-process dict resets on restart ⇒ a poison entry never reaches the DLQ). The rejected in-loop model would have blocked **all three streams for ~165 contiguous seconds** per poison entry, reintroducing exactly what BO-20a added `_DISPATCH_TIMEOUT_SECS` to prevent; the accepted cost of the chosen model is retry latency quantised to the reclaim cadence (~5 min to succeed on the 5th attempt, ~6 min to DLQ). (ii) **a dispatch `TimeoutError` is a FAILED dispatch** (retry, then DLQ) — acking it is a silent drop, which is the thing this ticket abolishes; consequence: BO-20a's `test_a_hung_sink_times_out_and_the_bus_keeps_draining` must be **rewritten** by slice 2 (its ack assertion inverts; its bus-keeps-draining half is preserved). Also recorded: the DLQ write must **not** call the sync `queue.enqueue_dlq` from the async loop (fresh sync client per call at `queue.py:49`, blocks the loop, invisible to the `consumer._get_client` fake), and `XAUTOCLAIM`'s **third** reply element — ids whose stream entry `_MAXLEN` trimmed away — must be unpacked and logged, because on redis-py 7.1.1 the common two-element unpack raises `ValueError` and wedges the whole **drain loop** every cycle — the `try` at `consumer.py:294-298` spans `_ensure_groups` *and* `_drain_once`, so a failing top-of-iteration reclaim stops the bus draining entirely, at ~1 Hz, forever (the reclaim pass must be wrapped so its failure degrades to "no reclaim this cycle"). Also newly recorded in §BO-20b: `JUSTID` is **forbidden** (it suppresses the very delivery-counter increment the retry design rests on, and `redis-py` returns a bare id list that unpacks into three names *without raising*); the `XPENDING`-before-`XAUTOCLAIM` read order is pinned (the other order moves the observable DLQ threshold from 5 deliveries to 6 and no fake-backed test can tell); `times_delivered` counts **deliveries, not failures**, so a crash-loop burns retry budget on a healthy event (mitigated by recording it on the DLQ row); the reclaim's 60 s min-idle bound is **per entry, not per batch** and is safe today only because the loop is serial — a constraint now sits on **BO-20e** to bound per-entry idle before concurrency is enabled, or the same event runs twice; **per-stream ordering is given up** by PEL-and-reclaim and is now listed as an accepted cost (a stale `taskUpdated` can start a run after a fresher one); and the attempt counter survives a *gateway* restart but **not a Redis** one (`xgroup_create(id="$")` re-creates the group at the tail after a flush, and `infra/` sets no `appendonly`). ⚠️ **Two further "enqueued but never dispatched" states are now recorded in §BO-20a** beyond that accepted drop: the `XACK` is deliberately unguarded (a raising `xack` means Redis is gone and must reach the backoff, not hot-loop), and the loop only ever reads `">"`, so an ack failure or a SIGTERM **mid-batch** strands the rest of that `XREADGROUP` reply in the PEL under the old pid's consumer name. Only BO-20b's reclaim pass recovers them, and only until `queue._MAXLEN` trims — so **BO-20b's done-when now requires the reclaim pass to run at startup**, not only on the periodic cadence, and carries an explicit open sub-question about the min-idle bound at startup. **BO-20f (Gmail + Zoho receivers reach ClickUp enqueue+emit parity) shipped 2026-08-02** and is what multi-channel event triggers actually needed; it is still **inert in prod** — `zoho_webhook_secret` and `gmail_pubsub_token` default to `""`, both receivers fail closed, and **OWNER-GATE (an agent can do neither):** provision `ZOHO_WEBHOOK_SECRET` + `GMAIL_PUBSUB_TOKEN` on the VPS (`.env.example` is itself OWNER-GATE under WS-2 — the plan-guard hook blocks agent writes to it) **and** point the provider subscription/webhook at `/webhooks/{zoho,gmail}`. The fail-closed posture is correct and must not be changed. ⚠️ **Not a greenfield build:** webhook→run was ALREADY wired — ClickUp → `ingestion/event_hooks.emit_event` → `workflows/triggers.dispatch_event` → `start_run` since commit `e20ea830`, and `/agent/webhook/{source}` (`routes/agent.py:3476-3478`) is a second live path that calls `dispatch_event` **directly** and is **untouched by the cutover** — so §BO-20 Q1's old "the consumer becomes the single dispatch path" was loose and is corrected there to "the only caller of `emit_event`". **Remaining: BO-20b slice 2 → c → (d, e)** — retry via PEL reclaim + honest `XACK` + DLQ hand-off, a drainable/visible DLQ, per-source rate limiting, bounded concurrency; all ✅ AGENT-SAFE, each waiting only on its predecessor. **WS-11 Slice 4 still waits**: `workflows_app.md:217` defines it as "(post-BO-20/BO-7): durable queued runs; …", and durable means a–e — without BO-20b a failed dispatch is acked and lost. BO-9 resolved as **not blocking** (the consumer owns its own long-lived async client; the producer's per-call sync `queue._client` stays BO-9's, untouched here). + +**Corrections applied 2026-08-09:** +- Current as moved; D15 coda added on the board: webhook secrets become per-org at MT-1a+. + +### WS-5 — **CI gates real** (BO-17/BO-18) + +**State cell (as of the move):** 🟡 Docs + +**Narrative (verbatim):** Un-gate evals, blocking gitleaks, coverage floor. ~~AGENT-SAFE~~ → **mixed: the highest-value item is a GitHub *settings* change an agent cannot make.** **Audited 2026-08-01 → NO-GO**: §F has zero testable "done when" ("per the existing plan", "a few green PRs", "for foundation packages"), its ratchet-plan anchor points at a path that moved to `specs/archive/` (3 stale citations live *in the workflow files*), and BO-17 reads ☐ while half of it shipped (blocking ruff-correctness + xenon, a frontend tsc/vitest job, gitleaks, per-PR health). **THE MISSING ITEM — why the 2026-08-01 F821 escape happened, in no doc today:** (1) `main` has **no branch protection** (`gh api …/branches/main/protection` → 404) — every "blocking" gate in these YAMLs is decorative; (2) commits pushed straight to main get **zero check-runs** (`15c8933f` had none); (3) `deploy.yml:56-58` lints with the *non-blocking full* `ruff check .`, **not** the `--select F821,…` correctness gate, so deploy went green over a broken tree; (4) PR #318's `pr-check` **failed on that exact F821 and merged anyway**. **Slice when specced (BO-17a "main-guard"):** add a `correctness` job to `deploy.yml` on push-to-main running the `--select` gate, deliberately NOT in the deploy job's `needs:` — loud, not blocking. AGENT-SAFE. **OWNER-GATE:** enabling branch protection / required checks, wiring any gate into `needs:`, removing `skip_tests`; BO-18's purge+rotation is WS-2's, not this row's. Refuted two long-standing beliefs: pr-check **does** cover the frontend, and it **does** run on non-main branches. + +**Corrections applied 2026-08-09:** +- The row's claim that `main` has no branch protection was FALSE when moved — protection enabled 2026-08-03 (`enforce_admins: true`; `required_status_checks` deliberately `null`, so docs-only PRs run zero checks). diff --git a/FOUNDATION_CONTINUATION.md b/FOUNDATION_CONTINUATION.md index 489774f35..81de3dea2 100644 --- a/FOUNDATION_CONTINUATION.md +++ b/FOUNDATION_CONTINUATION.md @@ -1,5 +1,7 @@ # Foundation Audit — Continuation & Handoff Guide +> ⚠️ **Historical session log.** 'LATEST STATUS' below is the state as of **2026-07-13** and is NOT maintained; its `origin/main` pin is weeks stale. For current state read `ai-company-brain/work_plan.md` §2; for foundation items `FOUNDATION_BUILDOUT_CHECKLIST.md`. *(Banner added 2026-08-09.)* + **Purpose:** everything still needed to finish the foundational audit + fixing, written so it can be picked up on a machine **with Postgres access**. Read this alongside `FOUNDATION_AUDIT_REPORT.md` (findings) and `FOUNDATION_BUILDOUT_CHECKLIST.md` (item tracker). This doc is the *executable* plan — concrete files, DDL, code sketches, test approach, and verification commands. **Branch:** originally `claude/foundation-architecture-audit-ftur3x`; **long since merged to `main`.** Current prod = `origin/main` = **`93e04be`** (deployed + verified live). diff --git a/ai-company-brain/AGENTS.md b/ai-company-brain/AGENTS.md index dfee53c95..841141034 100644 --- a/ai-company-brain/AGENTS.md +++ b/ai-company-brain/AGENTS.md @@ -1,184 +1,115 @@ # AGENTS.md — Planning Folder Navigation Guide -> **For AI agents:** Read this file first. It tells you what this project is, what has been built, and which file to read for each concern. -> **Organisation:** Fracktal Works · **Project:** CommandCenter · **Last updated:** 2026-07-29 +> **For AI agents:** Read this file first. It tells you what this project is and which file to read for each concern. **For what is built and what to do next, this file deliberately owns nothing:** `work_plan.md` §2 is the dispatch board; each owning spec's status header is the completion record (rule R4). A status table that lived here went stale and lied — it was retired on 2026-08-09 (work_plan.md §5 residual 1). +> **Organisation:** Fracktal Works · **Project:** CommandCenter · **Last updated:** 2026-08-09 --- ## What CommandCenter Is -CommandCenter is a **headless, self-mutating agent orchestration platform** for running a company. +CommandCenter is a **headless, self-mutating agent orchestration platform** for running a company — and, since 2026-08-08, **a product being prepared for sale to other companies** (WS-29, decision D15: tenant = `organization_id` row isolated by Postgres RLS; a deployment is a placement, not a tenant boundary; see `specs/saas_multitenancy.md`). -When a company event fires (webhook from ClickUp/Zoho/Odoo, cron schedule, or ambient signal), it: -1. Resolves the target specialist agent via a persistent local clone of that agent's GitHub repo. -2. Runs `git pull --ff-only` (< 0.5 s) to pick up any merged changes. -3. Injects credentials from the Integration Registry into the MAF orchestration context (via `mcp_servers=` config in `GitHubCopilotAgent`). -4. Executes the agent task (skills run as MAF tools or MCP servers inside `GitHubCopilotAgent`, with `HandoffBuilder` routing between specialist agents). -5. On failure: spawns an isolated Copilot SDK mutation container (`acb-mutation-runner`), applies a tested code fix to the live clone immediately, opens a GitHub PR as audit record. +When a company event fires (webhook from ClickUp/Zoho, cron schedule, or ambient signal), it: +1. Resolves the target specialist agent (persistent local clone or in-repo `apps/agents/*`). +2. Runs `git pull --ff-only` to pick up merged changes. +3. Injects credentials from the Integration Registry into the MAF orchestration context. +4. Executes the agent task (skills as MAF tools or MCP servers, `HandoffBuilder` routing). +5. On failure: spawns an isolated Copilot SDK mutation container, applies a tested fix, opens a GitHub PR as audit record. -Operators interact via a thin **Control Plane** (Next.js browser UI) with chat Q&A, HITL approvals, and observability. There is no in-app agent/skill editor — all authoring happens in VS Code + Git. +Operators interact via a thin **Control Plane** (Next.js) with chat Q&A, HITL approvals, and observability. There is no in-app agent/skill editor — all authoring happens in VS Code + Git. ---- +## Where state lives (read in this order) -## What Has Already Been Built (as of 2026-06-20) +1. **Root `AGENTS.md`** — global constraints (11, including D15 tenancy rules) and the DOX contract. +2. **`work_plan.md`** — the dispatch board: WS-0…WS-29 rows (§2), the agent-ready spec contract + standing rules R1–R5 (§1), decisions D1–D18 (§3), single-owner registry (§4), remediation record (§5), owner-gate registry (§6). **For ordering and ownership it wins over every spec, including `project_plan.md` §6.** +3. **The owning spec** for your concern — see the index below. Its status header is authoritative for that feature's state. +4. `FOUNDATION_BUILDOUT_CHECKLIST.md` (repo root) — foundation items BO-1…BO-23. -| Component | Status | Location | -|---|---|---| -| Core FastAPI gateway | ✅ Done | `apps/gateway/` | -| Ingestion workers (ClickUp, Zoho) | ✅ Done | `apps/ingestion/` | -| Entity graph (Postgres + pgvector) | ✅ Done | `infra/postgres/01_schema.sql` | -| Reconciler agent | ✅ Done | `apps/reconciler/` | -| Orchestrator (MAF `HandoffBuilder` + native workflow engine) | ✅ Done | `apps/orchestrator/` — LangGraph fully removed. DTS deferred to Phase 2; HITL via Action Broker (Postgres `approval_queue`). See ADR-026 in `system_architecture.md`, WBS 0.7. | -| Persistent clone cache + bot git identity | ✅ Done | `packages/acb_skills/acb_skills/loader.py` | -| Self-mutation node + Copilot SDK mutation container | ✅ Done | `apps/orchestrator/orchestrator/mutation.py`, `apps/orchestrator/mutation_runner.py`, `apps/orchestrator/Dockerfile.mutation` | -| Interactive operator chat (MAF AG-UI endpoint) | ✅ Done | `apps/gateway/gateway/main.py` — `add_agent_framework_fastapi_endpoint(app, agent, "/copilot/chat")`. The old `copilot_chat.py` SSE path and the Copilot SDK chat dispatch (`runtime: copilot`) have been **removed**. Copilot SDK is now mutation-container only. | -| Control Plane shell (Next.js, chat, SSO) | ✅ Done | `workbench/control_plane/` | -| Control Plane rich chat UI (SSE streaming, markdown, syntax highlight, tool-call blocks) | ✅ Done | `workbench/control_plane/src/components/MarkdownMessage.tsx`, `useAgentChat.ts`, `AgentChat.tsx`, `api/agent/chat/route.ts` | -| Control Plane model picker + agent switcher + stop-generation button | ✅ Done | `AgentChat.tsx` — VS Code Copilot-style UX | -| LLM routing + tiered models | ✅ Done | **In-process litellm SDK** via the gateway `/v1` endpoint (`apps/gateway/gateway/routes/v1_compat.py`, `packages/acb_llm/`). No proxy process; `infra/litellm/config.yaml` is vestigial (tier rows only, → BO-16). | -| GitHub Copilot model routes (`copilot/*`) | ✅ Done | Resolved in-process by `acb_llm` / the Copilot-SDK tier — not via a proxy config | -| Skills monorepo + loader | ✅ Done | `skills/`, `packages/acb_skills/` | -| Self-mutation GitHub PR automation | 🔲 Next | Phase 1 (WBS 1.3) | -| Eval CI gate on agent/skill PRs | 🔲 Next | Phase 1 (WBS 1.4) | -| **Dynamic multi-agent orchestration (MAF `as_tool()` registry)** | ✅ Done | `apps/orchestrator/orchestrator/agents.py` — every registered agent auto-exposed as a MAF tool at startup; LLM routes by description alone; zero hard-coded routing tables | -| **`delegate_to_agent` + `spawn_copilot_agent` tools** | ✅ Done | Orchestrator tools: delegate to any named specialist; spawn Copilot SDK container for creation/mutation tasks from chat | -| **Agent auto-repair (incompatibility → direct commit)** | ✅ Done | `AgentLoadError` triggers Copilot SDK sandbox with researcher+editor pattern; generates `agents.py` and commits directly — no PR | -| **Proactive skill sync (auto-wire new scripts)** | ✅ Done | `packages/acb_skills/acb_skills/loader.py` — after every pull, scans `skills/*/scripts/` for new scripts, injects tool wrappers, commits+pushes | -| **Agent add/remove via Control Plane UI** | ✅ Done | `workbench/control_plane/src/app/agents/` — paste GitHub URL, auto-fetches `config.json`, registers; dynamic agents persisted in `agents.json` | -| **Integration configure/test UI** | ✅ Done | `workbench/control_plane/src/app/integrations/` — live test against real APIs; writes to root `.env`; hot-reload | -| **LLM settings UI (tier picker, Gemini/OpenAI key save)** | ✅ Done | `workbench/control_plane/src/app/settings/models/` — per-tier model assignment; provider key save; LiteLLM health | -| **AG-UI → SSE translation (chat properly streams tool calls)** | ✅ Done | `workbench/control_plane/src/app/api/agent/chat/route.ts` — translates AG-UI events to delta/tool_start/tool_end for the UI hook | -| **Memory: Mem0 episodic + Graphiti bi-temporal KG** | ✅ Done | M2.8 — pgvector backend, Neo4j `--profile memory`, `/memory/*` API, injected into orchestrator + Copilot agents. See [`reference.md`](reference.md) §3 | -| **Fire-and-forget chat + live stream reconnection** | ✅ Done | Redis Streams + Postgres; agent continues after tab close, resumes live on reopen. See [`specs/archive/stream_reconnection.md`](specs/archive/stream_reconnection.md) | -| **Chat session history (auto-title + last-turn preview)** | ✅ Done | M2.6 — `chat_sessions.title`/`last_preview`; session list UI | -| **Integration OAuth framework (authorize→callback→refresh)** | ✅ Done | M2.6 — `routes/oauth.py`, HMAC-signed state, zoho-crm/clickup/google | -| **VS Code Copilot tools in chat (HITL Q, errors, repo memory, history, GitHub search, images)** | 🔄 Mostly done | See [`specs/archive/vscode_tool_integration.md`](specs/archive/vscode_tool_integration.md) | -| **Email app — multi-account client (Gmail/Outlook/IMAP) + AI assistant** | 🔄 In progress | M2.9 — `workbench/control_plane/src/app/email/`, gateway `routes/email/` (layered pkg), `apps/services/email_ingestion/`. Consolidated plan + roadmap: [`specs/email_app_master_plan.md`](specs/email_app_master_plan.md) | -| **App Workshop / Custom Apps — chat-built small software deployed in-platform** | 🔄 Phase 0–3b + T2 built | RFC + mockups: `docs/app-workshop/`. Gateway `routes/apps/` (lifecycle/files/publish+conformance-scan/runtime incl. budgeted `ai/complete`, sharing+consent, manifest `actions`), migration `114_custom_apps.sql`, `agent-app-builder` + executor `allow_session_workspace` binding, workbench `/build/apps` (gallery · Workshop · run page · `window.cc` bridge), granted apps registered as orchestrator agent tools (`orchestrator/app_tools.py`), live per-app token tracking in `/observability`. **T2 (real React apps)** built: `agent-app-builder/build/build_t2.mjs` (esbuild against a shared, deploy-provisioned react/react-dom/esbuild vendor cache — zero per-app `npm install`), manifest `entry`/`tier` already-existing dynamic resolution now covers `dist/bundle.html`. **T3 (server-side backend compute) scoped, not built** — gated behind BO-7 (platform-wide sandbox hardening, still ✖/absent); RFC's already-decided T3 substrate is Deno subprocesses, not containers. Platform contract is binding (§4.0): CommandCenter is the only backbone | -| **Task Manager app — GTD-philosophy client + `task-manager` agent (PM-agnostic: any tool via API or MCP)** | 🔄 capture/clarify live end-to-end | Frontend slices 1–2.5 + the capture/clarify **backend**: migration `48_task_manager_gtd.sql`, gateway `routes/tasks/` (20 endpoints), provider interface layer + **ClickUp connector** (multi-workspace `task_accounts`, encrypted tokens), `apps/skill-task-gtd/` + rewritten `apps/agent-task-manager/`, frontend wired live with mock fallback; **org-knowledge layer live** (`gtd_people` from agent-project-manager HR data → capability-aware delegation, §6.1). Resume: Slice 3 (Engage) · `/tasks/sync` pull. See [`specs/task_manager_app.md`](specs/task_manager_app.md) §9.1 | -| **Org access control — multi-user members, roles, per-user access** | ✅ Phase 1 done (→ multiplayer) | `infra/postgres/130_org_access_control.sql`, `packages/acb_auth/` (permissions.py + access.py + `require_permission`), gateway `routes/admin/` + `/auth/me`, workbench `/settings/members` + `/settings/roles` + nav/route gating. Spec: [`specs/org_access_control.md`](specs/org_access_control.md). Extended since: gateway-wide feature enforcement, per-member integration credentials + org-memory gate, service-identity/LLM-key split, signed agent webhook, default-deny authentication (closes BO-2). **Remaining scope — modules/teams, session sharing, entity-graph RLS — is handed off to the multiplayer agent collaboration workstream; see the spec's §10 handoff contract.** True agent isolation remains BO-7 | -| **Workflows app — visual automation builder + Module Studio + Copilot** | 🔄 Slices 1+2 built | Migration `132_workflows.sql`, gateway `routes/workflows/` (engine: graph compile → MAF `WorkflowBuilder`; manual/webhook/cron triggers; conversational module generator + AST validator + subprocess runner), workbench `/workflows` (React Flow editor, Module Studio, run console/history). Spec: [`specs/workflows_app.md`](specs/workflows_app.md) · RFC: `docs/workflow-editor/` · ADR-028 | -| `agent-sales` + `skill-zoho-ingest` | 🔲 Phase 2 | Phase 2 (WBS 2.2) | -| `agent-triage` + `skill-gmail-capture` | 🔲 Phase 2 | Phase 2 (WBS 2.3) | -| Meeting bot (Vexa + WhisperX) | 🔲 Phase 3 | Phase 3 (WBS 3.1) | -| WhatsApp ingest + push | 🔲 Phase 3 | Phase 3 (WBS 3.3) | -| Action Broker (approval-gated writes) | 🔲 Phase 4 | Phase 4 (WBS 4.1) | -| Odoo ingestor + strategy agent | 🔲 Phase 5 | Phase 5 (WBS 5.1/5.2) | - -**M1 milestone (Core Engine live) — PASSED 2026-05-25.** -Real cross-system cited Q&A over live Fracktal data confirmed. 22/22 tests green. - -**M2 milestone (Self-Mutation + Multi-Agent) — PASSED 2026-06-12.** -`Self_Mutation_Node` + Copilot SDK mutation container, dynamic multi-agent (`as_tool()` registry), `spawn_copilot_agent` + `delegate_to_agent`, agent auto-repair, and the inline eval gate are all ✅ done. Remaining Phase-1 cleanup: GitHub PR automation (WBS 1.3) and BYOK-forced metering (WBS 1.7). - -**Since M2 (all ✅):** M2.5 unified Copilot SDK Tier 1.5 streaming (CopilotKit removed) · M2.6 foundation hardening (chat history, cloud sandbox, integration OAuth, AG-UI generative events) · M2.7 universal tool injection · M2.8 Mem0 + Graphiti memory. -**M2.9 (🔄 in progress):** email app — multi-account client + AI assistant; Outlook end-to-end fixed (PR #4). - -**Dynamic multi-agent orchestration — DONE.** -Every registered agent (static + GitHub-registered) is exposed as a MAF `FunctionTool` via `agent.as_tool()` at gateway startup. LLM routes to the right specialist by description alone — no hard-coded routing table. WorkflowBuilder available for explicit sequential/fan-out pipelines. +Milestone history, kept to one line: M1 core engine 2026-05-25 · M2 self-mutation + multi-agent 2026-06-12 · M2.5–M2.9 (streaming, hardening, tool injection, memory, email) through 2026-07 · foundation audit + app buildout 2026-07/08 · WS-29 multi-tenancy started 2026-08-08. --- ## File Index — What to Read for Each Concern -The planning folder was consolidated on 2026-06-20 (15 files → 5 + `specs/`). - | Concern | File | |---|---| -| **Requirements + roadmap + WBS** (what / when / how much — single source) | [`project_plan.md`](project_plan.md) | -| **System design: containers, data model, ADRs** | [`system_architecture.md`](system_architecture.md) | -| **How to build a compatible agent repo** | [`agent_repo_compatibility.md`](agent_repo_compatibility.md) | -| **Library notes: MAF, Copilot SDK, memory** | [`reference.md`](reference.md) | -| **Per-feature specs** | [`specs/`](specs/) — see the status index below | +| **What order, who owns it, what's gated** (single source) | [`work_plan.md`](work_plan.md) | +| **Requirements + long-horizon roadmap** (sequencing yields to work_plan) | [`project_plan.md`](project_plan.md) | +| **System design: containers, data model, ADRs** (⚠️ stale-warning in header) | [`system_architecture.md`](system_architecture.md) | +| **How to maintain an existing external agent repo** (⚠️ superseded premise) | [`agent_repo_compatibility.md`](agent_repo_compatibility.md) | +| **Library notes: MAF, Copilot SDK, memory** (⚠️ stale-warning in header) | [`reference.md`](reference.md) | +| **Workspace / artifact model for agents** | [`agents-workspaces-artifacts.md`](agents-workspaces-artifacts.md) | +| **Per-feature specs** | [`specs/`](specs/) — index below | ### Per-feature specs (`specs/`) -Status: 🟢 live/shipped · 🔄 in progress · 🔲 planned/not started. *(Index reconciled against code 2026-07-13.)* +Status: 🟢 live/shipped · 🔄 in progress · 🔲 planned/not started. *(Index completed 2026-08-09 — 16 missing rows added; statuses are one-line pointers, the spec's own header wins.)* -**Only forward-looking / living specs are listed here.** 13 shipped-or-historical specs were moved to -[`specs/archive/`](specs/archive/README.md) (each verified live in code, with residual open work carried -forward). Foundation status of record is `FOUNDATION_BUILDOUT_CHECKLIST.md` (BO-*) — the specs below defer -to it and to `competitive_hardening_2026-07.md` (CH-*) rather than re-describe those gaps. +**Only forward-looking / living specs are listed.** Shipped-or-historical specs live in [`specs/archive/`](specs/archive/README.md). Foundation status of record is `FOUNDATION_BUILDOUT_CHECKLIST.md` (BO-*). | Spec | Concern | Status | |---|---|---| -| [`core_module_map.md`](specs/core_module_map.md) | **Living architecture hub** — orchestrator module→file map, the parent of the (now-archived) core-loop/context zoom-ins | 🟢 living reference | -| [`competitive_hardening_2026-07.md`](specs/competitive_hardening_2026-07.md) | **Competitive hardening** — Hermes/OpenClaw learnings (`CH-*`) annealed onto BO-1/5/7/12/14 + new BO-20/BO-21; Phase-5 Annealer = self-improving-skills home. Source `/COMPETITIVE_COMPARISON.md` | 🔲 planned (annealed, no code) | -| [`multiplayer_prior_art_qm_2026-08.md`](specs/multiplayer_prior_art_qm_2026-08.md) | **Multiplayer prior art** — `yc-software/qm` learnings (`QM-*`) annealed onto WS-10 (steer before floor control; no ambient credentials in a room) and WS-23 (skills index, bodies on demand). Records that an outside team reproduced our least-cleared-viewer rule independently | 🔲 reference-only (annealed, no code; owns no status) | -| [`harness_hardening_2026-07.md`](specs/harness_hardening_2026-07.md) | **Harness gap queue** (HH-1..8) vs awesome-harness-engineering | 🔄 HH-1/4/5 shipped; HH-2/HH-3 mechanism-only, **not enforced** (audit M5/H9); HH-6/7 deferred | -| [`permissions_sandbox_b6.md`](specs/permissions_sandbox_b6.md) | Permission policy + sandbox design | 🔄 policy layer shipped but audit gate is a name-only **no-op (M5)**; sandbox not started → **BO-7 / BO-14** | -| [`observability_e2.md`](specs/observability_e2.md) | Observability — activity feed, cost, agent office | 🔄 Redis activity/cost feed **shipped**; distributed/OTel tracing **dead** → **BO-5** | -| [`chat_ux.md`](specs/chat_ux.md) | **Chat master** — thinking/progress/tool rendering (absorbed the two archived chat audits) | 🔄 Phase 1 shipped; §12 AG-UI event backlog open | -| [`email_app_master_plan.md`](specs/email_app_master_plan.md) | **Email master** — consolidated state + prioritized completion roadmap (absorbed the inventory, parity plan, tool plan; evidence in `specs/archive/email_feature_review_2026-07.md` — archived 2026-08-01) | 🔄 Phase 1 "stop the lying" open; #113 sweep armistice done | -| [`task_manager_app.md`](specs/task_manager_app.md) | **Task Manager (GTD)** — client + `task-manager` agent + provider layer | 🔄 capture/clarify/organize + provider **sync-pull** + Engage "Now" live; **Waiting-For live 2026-08-02** (grouped view + overdue/stale flags; `gtd_waiting.expected_by` = an explicit promise only — NULL ⇒ the overdue line reads the item's live `due_at`, never a derived copy); open: follow-up nudges (owner-gated), Action-Broker-gated push, Weekly Review, Horizons | -| [`task_manager_harness_2026-07.md`](specs/task_manager_harness_2026-07.md) | Task-manager × harness engineering (app-layer sibling) | 🔄 Tier 1 shipped (2026-07-03); Tier 2 planned | -| [`project_management_app.md`](specs/project_management_app.md) | **Projects app (WS-27)** — native org-level project management in the People Center, sliced into every Center as (app + scope): departments→projects→subprojects→tasks→subtasks (`pm_*`, Paca-shape two-self-FK hierarchy), grant-based scoping on the shipped `email\|group:\|org` vocabulary, per-view fractional ordering, single activity spine; ClickUp two-way coexistence sync (three-way field merge, broker-gated push — **blocked on BO-1a/BO-1b**) then cutover + retirement; personal `/tasks` mirror via an internal provider; `pm.*` events feed `/workflows` and assign-to-`agent:` dispatches real runs. **Read §8 D-PM-8/9/10 (owner-answered 2026-08-06) before building:** no portfolio layer (grants are the only grouping axis) · agent edits to ClickUp-linked tasks are treated exactly like human edits, and D-PM-9's Cost paragraph names what that does and does not guarantee · Spaces map to Centers explicitly from agent-proposed suggestions, an agent may propose a mapping and must never apply one, and an unmapped Space still imports in full | 🟢 **a + b + d + e BUILT 2026-08-06** (schema + grant read model; ClickUp importer + mapping plan; `/projects` UI + Center projections; the personal lens). ⚠️ **D-PM-6 was REVISED 2026-08-06** — one task store, not a mirror: read it before touching `/tasks`. **Not deployed, and neither import endpoint has been run**; c gated on BO-1a/BO-1b; f, g, h open | -| [`people_center_app.md`](specs/people_center_app.md) | **People Center (WS-28)** — the directory, person page, org chart, capability search and seats matrix, plus the four seams where People meets Projects. Owns **surfaces, not facts** — every fact is cited to its owning spec. Read §2 first: there are **two people stores on purpose** (`app_user` = can they sign in; `gtd_people` = who are they and what can they do), the directory must include people with no login, and migration 49's key shape (UNIQUE on `name`, nothing on `email`) makes the join ambiguous until WS-28a fixes it | 🔲 spec'd 2026-08-06, nothing built | -| [`paca_pm_research_2026-08.md`](specs/paca_pm_research_2026-08.md) | **Paca PM-platform research** — `Paca-AI/paca` v0.11.0 deep dive (Apache-2.0; patterns, no code): hierarchy/statuses/ordering/views data model, trigger→condition→action automation graph, assignment→agent dispatch chain, MCP tool-design lessons, with a 14-row adopt/adapt/refuse table annealed into WS-27 | 🟢 research complete (reference-only, owns no work) | -| [`llm_caching_memory.md`](specs/llm_caching_memory.md) | Prompt caching (ADR-008) + session memory | 🔄 caching **shipped & wired**; session-memory shipped but **inert by default** (→ BO-21); Phase 7 open | -| [`mcp_plugin_integration.md`](specs/mcp_plugin_integration.md) | MCP servers vs Claude plugins vs REST | 🔄 MCP half **built** (`_inject_mcp_servers`); plugin store not started | -| [`tenancy_and_visibility.md`](specs/tenancy_and_visibility.md) | **Tenancy + visibility — the architecture of record for who can see what.** Records two owner calls of 2026-08-03: (a) **the tenant boundary is THE DEPLOYMENT** — one deployment per tenant, row-level org isolation explicitly NOT built, `organization_id` stays a label not a mechanism; (b) the **visibility model** private → Center → org plus ad-hoc cross-Center groups by invite, mapped onto the shipped `email \| group: \| org` subject vocabulary. Also answers "what makes a project a team's project" (an explicit `group:` grant), carries the per-surface gap table for going app by app, and names what is out of scope (row-level tenancy, org switcher, multi-org users). **Read before designing any sharing or scoping on a new surface** | 🟢 architecture of record (owner-answered 2026-08-03) | -| [`backup_and_restore.md`](specs/backup_and_restore.md) | **Backup & restore (BO-23)** — the measured recovery position (Hostinger VM images only: weekly, 2 retained, newest 5 days old, ~58 min, whole-machine granularity), plus `scripts/backup_db.sh` / `scripts/restore_db.sh` and the runbook. `apply_migrations.sh` now **fails closed** without a pre-migration dump. ✅ **Scheduling and the first restore both closed 2026-08-05**: `acb-backup.timer` installed and enabled (nightly 02:30 UTC, `Persistent=true`), and the deep verify has now actually run — `Result=success`, `live=228 restored=228`, `restore verified`. ⚠️ Running it for real immediately caught a bug the timer would have hidden: the verify log went to `/tmp`, and `fs.protected_regular=2` forbids **even root** from opening an existing file there owned by another user, so every nightly run would have exited 1 with the dump itself perfectly fine (fixed to `$DEST`, PR #359). **`BACKUP_REMOTE` is DEFERRED by owner decision 2026-08-05** (§4.2) — accepted risk: backups survive a bad migration or a dropped table, but **not** losing the disk, box or provider account, where recovery falls back to the weekly two-deep Hostinger VM image (§1). The script warns on every run by design; **do not silence the warning to make it green** | ◐ scheduled + restore-verified 2026-08-05; off-box copy deferred by decision | -| [`deploy_delivery_path.md`](specs/deploy_delivery_path.md) | **Deploy delivery path (WS-25)** — how a commit on `main` becomes running code on the VPS, and why it currently does not. ⚠️ **Read the §0 correction first:** this spec's original claim that five PRs were stranded "including the OAuth authorize fix" was **wrong** — #354/#355/#356 are live, because one successful deploy `git reset --hard`s to `origin/main` and lands everything merged up to that instant. Only #357 + #358 are stranded, and they are documentation plus one script: **zero production impact to date**, and the remediation needs no deploy at all. Measured: GitHub's runners cannot reach the box **on any port** (`Connection timed out` *and* `workbench=000000`), while the box was idle, unrebooted, and answering the operator in 240 ms — and reaches GitHub outbound in 29 ms. **Inbound broken, outbound fine**, which is the input to every option. ⚠️ Read §3 before proposing a pull-based fix: `DEPLOY_SCRIPT` is a 435-line script living only in `deploy.yml`'s `env:`, so the naive version duplicates it and creates two deploy paths that drift; and a box running the script *from the checkout* has it `git reset` out from under bash mid-read. §6 is the hand-driven stopgap, whose preconditions (a *tested* restore, a live backup timer) are true as of 2026-08-05 09:29. **Blocks `GATEWAY_INTERNAL_TOKEN` rotation** — the prescribed method is a redeploy | 🔴 BROKEN (measured 2026-08-05); all acceptance OWNER-GATE | -| [`user_management_contract.md`](specs/user_management_contract.md) | **⭐ READ THIS BEFORE BUILDING OR MODIFYING ANY APP.** The binding contract for identity, membership and authorization — the four-hop identity chain, the member lifecycle and every door between its states, the permission vocabulary and the one rule about extending it, and **ten rules each learned by breaking it** (never navigate the browser at the gateway; never add a route to `PUBLIC_ROUTES` to make it reachable; never take the acting identity from a query parameter; the `/admin` floor is per-route; 404 never 403; keyword-only projections; what a destructive route owes; report the cascade; hiding a control is a courtesy; case-insensitive both sides). **It owns RULES, not FACTS** — every fact is cited to its owning spec, and anchors are symbols rather than line numbers because this corpus has repeatedly shipped stale ones. §5 is the trap list with the incident that found each; §6 is what is NOT closed | 🟢 Binding (2026-08-05) | -| [`colleague_onboarding.md`](specs/colleague_onboarding.md) | **Colleague onboarding (WS-24)** — the readiness gate before member #2 (four blocking items, AGENT-SAFE vs OWNER-GATE, each with a done-when), the invite → role → Center-group → verify runbook against the real endpoints, and **THE CAPABILITY MATRIX**: what a colleague on each role can actually see, per app, every cell carrying the `file:line` that settles it (and `UNVERIFIED` where it could not be established). Executable half: `scripts/onboarding_preflight.py` — **agent-safe to write, `--mode local` is an agent's only mode.** Read §3.0 before quoting any role's grants: they come from **two** migrations, and `data:org:read` grants nothing (D14). §6 is the provisioning gap (N6): an unprovisioned sign-in used to be logged and discarded, so nobody could see who was locked out — **N6a built + repaired twice 2026-08-04** (migration 143 `access_request`, `resolve_access(record_request=)`, `/admin/members/requests`, a Requests tab). Read §6 *Repair round 2* before quoting done-when 7: it holds the **approve matrix** (what approving does about an address that already has an `app_user` row — refuse for `suspended`/`removed`, leave an `active` member's roles alone, provision only `invited`/absent) and supersedes dw7's "approving twice is not an error". **Merging it is OWNER-GATE** because the deploy replays migrations | 🔴 NOT READY — G4 CLOSED, **3 blockers open** (Caddy strip, `GATEWAY_INTERNAL_TOKEN`, BO-23 backups) | -| [`org_access_control.md`](specs/org_access_control.md) | **Org access control / multi-tenant user management** — members, roles, per-user allow/deny overrides, feature + agent gating, per-member integration credentials, default-deny auth. **§10 is the handoff contract for the multiplayer agent collaboration workstream — read it before designing multiplayer** | 🟢 Phase 1 shipped; remaining scope (modules/teams, session sharing, transcript + memory exposure) **transferred to multiplayer collaboration** | -| [`multi_user_organization_research.md`](specs/multi_user_organization_research.md) | Multi-user / org account research — identity, roles, tenancy, memory + credential scoping, SaaS multi-tenancy | 🔄 §4 identity/roles now implemented via `org_access_control.md`; §5 modules, §7 memory scoping, §8 credential scoping remain research. ⚠️ **§9 entity-graph RLS and §17 SaaS tenancy are SUPERSEDED for planning purposes by `tenancy_and_visibility.md` §1 + §6** (2026-08-03 owner call: one deployment per tenant; row-level org isolation explicitly not built). Read them as research into a path that was considered and declined, not as queued work | -| [`chat_agent_framework_review_2026-07.md`](specs/chat_agent_framework_review_2026-07.md) | **Chat + agent framework review** — dual-runtime verdict (MAF framework, Copilot as coding engine), orchestration/memory/artifact/HITL/co-authoring gaps, prioritized plan | 🟢 review complete | -| [`single_agent_chat_bug_audit_2026-07.md`](specs/single_agent_chat_bug_audit_2026-07.md) | **Single-agent chat bug audit** — past issues verified fixed; 7 confirmed live bugs (Tier-1 loop-trip crash, Copilot retry duplication, unbounded resumed-session context, relay truncation/ack holes) + fix plan | 🟢 audit complete | -| [`generative_ui_2.md`](specs/generative_ui_2.md) | **Generative UI 2.0** — immersive HITL UI: surface(panel)/hitl(blocking) on emit_generative_ui, 11-template library (recipe/flight/train/form/optionPicker…), side-panel genUI tabs, scenario→element map | 🔄 Phase 1 shipped | -| [`agent_coding_skill.md`](specs/agent_coding_skill.md) | **Agent coding skill** — Copilot SDK as a capability for MAF agents: `code_task` (bounded coding session, manifest-first `agent-data/SCRIPTS.md` contract) + `run_script` (zero-LLM reuse); blob-store-durable scripts, workspace jail + secret-scrubbed subprocess env | 🟢 Phase 1 shipped | -| [`drawio_integration.md`](specs/drawio_integration.md) | **draw.io** — architecture, tickets ST-DRW-01…13 (master) | 🔲 proposed — **genuinely unbuilt** | -| [`drawio_diagram_svc_contract.md`](specs/drawio_diagram_svc_contract.md) | draw.io — `diagram-svc` wire contract (sub-doc of the master) | 🔲 proposed — unbuilt | -| [`note_taker_app.md`](specs/note_taker_app.md) | **AI Note Taker (`/notes`)** — browser record (mic + Chromium tab audio) → pluggable STT (`acb_stt`: BYOK cloud + self-host faster-whisper/WhisperX + open diarization) → grounded notes via `acb_llm` template compiler → HITL action-items→`/tasks`, recap→`/email`, share→`/chat`; activates the dormant `meeting`/`action_item` tables | 🔄 slice 0 built (migration 94, `acb_stt`, gateway `routes/notes/`, upload→transcribe→segments UI); slice 1 (recorder + SSE + notes generation) next | -| [`note_taker_research_2026-07.md`](specs/note_taker_research_2026-07.md) | Note Taker — research appendix (sub-doc): Meetily deep dive, 18-project landscape survey, mid-2026 ASR/diarization SOTA, browser-capture constraints, license watch-list | 🟢 research complete | -| [`whatsapp_calls_note_taker.md`](specs/whatsapp_calls_note_taker.md) | **WhatsApp calls → Note Taker** — feasibility study + UX design for note-taking on WhatsApp voice calls: the four capture surfaces (Cloud API Business Calling · WhatsApp Web · whatsmeow+meowcaller · device upload), why group calls are impossible officially and only reachable via the unofficial bridge, the "notetaker is a contact you add to the call" UX, and the consent model | ✅ **Surface C shipped + deployed 2026-08-02** — dialer, chat Call button, two-way browser audio, server-side recording + playback, readiness diagnostics (§12). Transcription not yet wired; Phase A (official Cloud API 1:1) still gated on Meta enabling calling for the WABA | -| [`workflows_app.md`](specs/workflows_app.md) | **Workflows app (`/workflows`)** — visual automation builder: DB-persisted graphs compiled to MAF Workflows, webhook/schedule/manual triggers, Module Studio (conversational pure-transform code modules), served node catalog. Engineering RFC: `docs/workflow-editor/README.md`. Policy: ADR-028 | 🔄 Slices 1+2 built (migration 132, gateway `routes/workflows/` incl. copilot + event triggers + approval pause/resume via the Action Broker inbox, `/workflows` editor) | -| [`department_centers.md`](specs/department_centers.md) | **Department Centers** — one platform, many projections: nomenclature (Center/module/group/Workshop), nav IA (Personal Center / Centers / Studio / Admin), `center.*` feature gating, and the Phase B–E work plan (groups admin UI → scoped slices → dashboards/Company Center → AI budgets). Registry: `workbench/control_plane/src/lib/centers.ts` | 🔄 Phase A shipped (nav scaffold, `/centers/` landings, migration 140); Phase B (groups admin UI) shipped pending review. **Phase C is now four lettered bullets with per-item acceptance and gate labels (2026-08-03): C1 tasks team slice 🟢 AGENT-SAFE (grant table = D13, no `role` column; a caller-reachable `POST/DELETE /tasks/projects/{id}/grants` is a done-when, not an implied one) · C2 shared mailboxes 🟢 AGENT-SAFE for the doc action only, build blocked — no owner in fact · C3 team-instanced agents 🟢 narrow, and its migration is pre-provisioning with columns intentionally unread (the roster it pointed at names seven agents that do not exist) · C4 per-Center approvals 🔴 OWNER-GATE.** Read C3's two trap blockquotes before touching any agent's `instancing` | -| [`skills_registry.md`](specs/skills_registry.md) | **Skills registry + per-agent skill toggles (WS-23)** — code-declared skill families with measured token cost, Integrations → Skills catalog tab, per-agent enable/disable stored in `agent_skill_setting` (intersection-only, core floor non-toggleable), tools addendum generated from the enabled set | 🔄 S1+S2+S3-generation shipped pending review 2026-08-01; remaining: owner-gated `SKILLS_FAIL_CLOSED` flip (ships OFF) | -| [`skills_scope_out.md`](specs/skills_scope_out.md) | **Skills scope-out (WS-23 S3)** — evidence-based general-vs-specialised classification of the injected skill families: per-agent recommendation table, the GENERAL `DEFAULT_PROFILE` (core/memory/workflows/apps), SPECIALISED families (history→orchestrator, coding→apis-config), flip checklist + measured token costs | 🔲 proposal for owner review 2026-08-01; the fail-closed flip is owner-gated and OFF | -| [`../work_plan.md`](work_plan.md) | **Work Plan of Record — the dispatch board.** Single sequencing doc for independent-agent dispatch: WS-0..22 workstreams with gates, the agent-ready spec contract (7 rules + R1–R4), decisions D1–D9 resolving cross-doc conflicts, the single-owner registry for duplicated work, the doc-remediation backlog, and the owner-gate registry. **Read before dispatching any agent on spec'd work; for ordering/ownership it wins over every spec** | 🟢 active (2026-07-31 audit); WS-0 Tier-1 doc fixes are the first dispatch | +| [`saas_multitenancy.md`](specs/saas_multitenancy.md) | **⭐ SaaS multi-tenancy (WS-29)** — architecture of record for selling CommandCenter: tenancy = `organization_id` + RLS at the connection seam (D15), modules/entitlements, AI credit resale, billing; §6 blockers; §11 tickets MT-0…MT-5 | 🟢 architecture of record (2026-08-08); Phase 0 built; H1 scratch-verified 2026-08-09, prod apply = PR #404 | +| [`saas_multitenancy_handover.md`](specs/saas_multitenancy_handover.md) | **⭐ WS-29 execution runbook** — H1→H8 with gates; §0 paste-ready brief; H2 (561 call sites) is the long pole; H2-before-H3 is non-negotiable | 🟢 in execution — H1 scratch gate passed 2026-08-09 | +| [`saas_multitenancy_implementation.md`](specs/saas_multitenancy_implementation.md) | Multi-tenancy build shapes: RLS migration template, `tenant_session()` seam, ratchets, control-plane DDL, runbooks, ten-trap table | 🟢 binding build reference (2026-08-08) | +| [`tenancy_and_visibility.md`](specs/tenancy_and_visibility.md) | **Visibility architecture of record (§2–§5)**: private → Center → org ladder, `group:` project grants, per-surface gap table. ⛔ §1/§6 tenancy half superseded 2026-08-08 by D15 | 🟢 for visibility; ⛔ §1+§6 superseded | +| [`user_management_contract.md`](specs/user_management_contract.md) | **⭐ Read before building/modifying ANY app** — identity chain, lifecycle, permission vocabulary, eleven binding rules (R11 = tenant never from input) | 🟢 binding (2026-08-05; R11 added 2026-08-08) | +| [`org_access_control.md`](specs/org_access_control.md) | Intra-org access model: members, roles, overrides, feature gating, default-deny auth (⚠️ header notes the per-deployment framing predates D15) | 🟢 Phase 1 shipped; tenancy axis reopened as WS-29 MT-1a/H6 | +| [`colleague_onboarding.md`](specs/colleague_onboarding.md) | **WS-24** — readiness gate before member #2, invite runbook, role×app capability matrix, `scripts/onboarding_preflight.py` | 🔴 2 gates + 1 decision open (G1 Caddy · G2 token · N5); G3 backups closed 2026-08-07 | +| [`multi_user_organization_research.md`](specs/multi_user_organization_research.md) | Multi-user/org research. §17 is background to WS-29; **§17.3's header-based tenant resolution is REJECTED by name** (R11) | 🔄 research; input to `saas_multitenancy.md` | +| [`crm_app.md`](specs/crm_app.md) | **CRM app (WS-26)** — native CRM + Zoho retirement; sync engine, agent tools (read + confirm-gated write), reports | 🟢 a–g merged + deployed; D5 autolead PR #403 open; h/i/e open | +| [`project_management_app.md`](specs/project_management_app.md) | **Projects app (WS-27)** — native PM + ClickUp retirement; `pm_*` hierarchy, grant-scoped views, automation, one task store (D-PM-6) | 🟢 a–n merged; c/g/h gated; §11.12 open defect | +| [`people_center_app.md`](specs/people_center_app.md) | **People Center (WS-28)** — directory, org chart, capability search, seats; two people stores on purpose | 🟢 a+b+b-write built; c–e dispatchable; f owner-gate | +| [`task_manager_app.md`](specs/task_manager_app.md) | **Task Manager (GTD)** — capture/clarify/organize/engage + provider sync (WS-18) | 🔄 Waiting-For built pending review; Weekly Review needs its JSON contract | +| [`task_manager_harness_2026-07.md`](specs/task_manager_harness_2026-07.md) | Task-manager × harness engineering | 🔄 Tier 1 shipped 2026-07-03; Tier 2 planned | +| [`task_manager_hr_planning_and_memory.md`](specs/task_manager_hr_planning_and_memory.md) | HR/people data + capability layer (WS-27/WS-28 read it, never rebuild it) | 🟢 design of record (2026-07-16) | +| [`email_app_master_plan.md`](specs/email_app_master_plan.md) | **Email master** — consolidated state + completion roadmap (WS-17) | 🔄 live daily-driver; 3 owner calls pending; 2nd mailbox connected 2026-08-05 | +| [`calendar_focus_os.md`](specs/calendar_focus_os.md) | **Calendar / Focus OS** — §9 canonical acceptance for F2/F3; §5 canonical `gtd_time_blocks` (WS-21) | 🔄 F0+F1 shipped; F2 = four slices | +| [`calendar_timeboxing.md`](specs/calendar_timeboxing.md) | Calendar timeboxing P0–P4; §13 canonical for P4 external sync | 🟢 P0–P3 shipped; P4 owner-gated (OAuth creds) | +| [`calendar_ai_review.md`](specs/calendar_ai_review.md) | Calendar AI review record (cited by migrations 92/97/100) | 🟢 review record, triaged | +| [`calendar_ux_review.md`](specs/calendar_ux_review.md) | Calendar UX audit; sole home of block-reminders item | 🟢 audit record | +| [`note_taker_app.md`](specs/note_taker_app.md) | **AI Note Taker (`/notes`)** — record → STT → grounded notes → HITL actions (WS-19) | 🔄 slices 0–2 built + bot Phase 1; share-to-chat open | +| [`note_taker_research_2026-07.md`](specs/note_taker_research_2026-07.md) | Note Taker research appendix | 🟢 research complete | +| [`meeting_bot_platform_plan.md`](specs/meeting_bot_platform_plan.md) | Meeting-bot joining layer (RTMS, Attendee ELv2 — ⚠️ resale re-evaluation flagged 2026-08-09) | 🟢 plan of record (2026-07-30) | +| [`live_meeting_copilot.md`](specs/live_meeting_copilot.md) | Live meeting copilot | 🔄 Phases A–D built (~2026-07-28); E planned | +| [`whatsapp_message_manager.md`](specs/whatsapp_message_manager.md) | **WhatsApp manager** — W0–W14 (WS-20 activation) | ✅ built; activation owner-gated (Meta review) | +| [`whatsapp_calls_note_taker.md`](specs/whatsapp_calls_note_taker.md) | WhatsApp calls → Note Taker (four capture surfaces) | ✅ Surface C shipped 2026-08-02 | +| [`workflows_app.md`](specs/workflows_app.md) | **Workflows app** — graphs → MAF, triggers, Module Studio (WS-11; D6 winner) | 🔄 Slices 1+2 built; Slice 3 = 8.3a/b/c | +| [`department_centers.md`](specs/department_centers.md) | **Department Centers** — Centers as projections, nomenclature (R3), Phase B–E plan (WS-13…16) | 🔄 Phase A+B built; C re-audit flag on C1 | +| [`agent_architecture.md`](specs/agent_architecture.md) | **Agent architecture A0→C** (WS-8) — single runtime, manifests, declarative builder; §12.1 read-first (unwired substrate) | 🔄 A0 half done; §12.2 = tickets | +| [`agent_file_and_memory_framework.md`](specs/agent_file_and_memory_framework.md) | Agent file + memory framework (canonical contract) | 🟢 Parts 1–2 built | +| [`agent_persistence_implementation.md`](specs/agent_persistence_implementation.md) | Agent persistence (blob store) implementation | 🟢 live (PR #60 merged) | +| [`agent_coding_skill.md`](specs/agent_coding_skill.md) | `code_task` + `run_script` — Copilot SDK as MAF capability | 🟢 Phase 1 shipped | +| [`agent_platform_hardening_2026-07.md`](specs/agent_platform_hardening_2026-07.md) | Platform hardening audit; §1.2 = isolation-ladder table of record (⚠️ §1.5 T2 parking re-scoped by D16) | 🔄 review record | +| [`permissions_sandbox_b6.md`](specs/permissions_sandbox_b6.md) | Permission policy + sandbox (WS-3; P5-a…d) | 🔄 P5-a/b.1 shipped; T2 parked → pooled-cutover precondition (D16) | +| [`memory_architecture.md`](specs/memory_architecture.md) | Memory tiers (WS-9: 3b/3c/4; 3a′ remainder is WS-10's) | 🔄 3a′ substrate shipped | +| [`llm_caching_memory.md`](specs/llm_caching_memory.md) | Prompt caching + session memory | 🔄 caching shipped; session memory inert (BO-21) | +| [`multi_agent_orchestration.md`](specs/multi_agent_orchestration.md) | Framework uplift — **Phase 4 only** lives (D6; WS-12) | 🔄 0 dispatchable PRs (owner target choice) | +| [`skills_registry.md`](specs/skills_registry.md) | **Skills registry + per-agent toggles** (WS-23) | 🔄 S1–S4 built pending review; flips owner-gated | +| [`skills_scope_out.md`](specs/skills_scope_out.md) | Skills scope-out: general vs specialised, flip checklist | 🔲 proposal for owner review | +| [`groups_sessions_authority.md`](specs/groups_sessions_authority.md) | Groups, sessions, intersection authority | 🟢 steps 1–4 shipped | +| [`mcp_plugin_integration.md`](specs/mcp_plugin_integration.md) | MCP servers vs plugins vs REST | 🔄 Phase A shipped (MAF-side gap = WS-8c) | +| [`observability_e2.md`](specs/observability_e2.md) | Observability §7 (WS-6a–i) | 🔄 6a+6c built; 6b/d/e held NO-GO | +| [`backup_and_restore.md`](specs/backup_and_restore.md) | **Backup & restore (BO-23)** — scripts, timer, restore runbook | 🟢 scheduled + restore-verified; off-box copy deferred by owner | +| [`deploy_delivery_path.md`](specs/deploy_delivery_path.md) | **Deploy delivery (WS-25)** — commit → box | 🟡 recovered 2026-08-06/07 UTC; tip health-verify failure open | +| [`harness_hardening_2026-07.md`](specs/harness_hardening_2026-07.md) | Harness gap queue (HH-1..8) | 🔄 HH-1/4/5 shipped; 6/7 deferred | +| [`competitive_hardening_2026-07.md`](specs/competitive_hardening_2026-07.md) | Hermes/OpenClaw learnings (CH-*) | 🔄 annealed; BO-20 items building | +| [`multiplayer_prior_art_qm_2026-08.md`](specs/multiplayer_prior_art_qm_2026-08.md) | `qm` prior art (QM-*) | 🟢 reference-only | +| [`paca_pm_research_2026-08.md`](specs/paca_pm_research_2026-08.md) | Paca PM research | 🟢 reference-only | +| [`chat_ux.md`](specs/chat_ux.md) | Chat master — §12 VII–XI live remainder | 🔄 Phase 1 shipped; §12.3 superseded | +| [`chat_agent_framework_review_2026-07.md`](specs/chat_agent_framework_review_2026-07.md) | Chat + framework review | 🟢 review complete | +| [`single_agent_chat_bug_audit_2026-07.md`](specs/single_agent_chat_bug_audit_2026-07.md) | Single-agent chat bug audit | 🟢 audit complete | +| [`generative_ui_2.md`](specs/generative_ui_2.md) | Generative UI 2.0 (HITL templates) | 🔄 Phase 1 shipped | +| [`core_module_map.md`](specs/core_module_map.md) | Living architecture hub (orchestrator module map) | 🟢 living reference | +| [`drawio_integration.md`](specs/drawio_integration.md) | draw.io master (ST-DRW-01…13) | 🔲 unbuilt; needs an owner (WS-22) | +| [`drawio_diagram_svc_contract.md`](specs/drawio_diagram_svc_contract.md) | draw.io wire contract | 🔲 unbuilt | +| [`../work_plan.md`](work_plan.md) | **Work Plan of Record — the dispatch board.** WS-0…WS-29 with gates; contract + R1–R5; decisions D1–D18; single-owner registry; owner-gate registry. **Read before dispatching any agent; for ordering/ownership it wins over every spec** | 🟢 active; consolidation pass 2026-08-09 | --- ## Non-Negotiable Constraints (AI Agents Must Respect These) -| # | Constraint | -|---|---| -| 1 | **No in-app agent/skill *code* editing.** All code authoring is VS Code + Git. The Workflows app is the sanctioned carve-out (ADR-028): workflows are DB-persisted *configuration* orchestrating code-authored agents, compiled to MAF Workflows — never generated agent code, never a second runtime. | -| 2 | **No credentials in agent or skill repos.** `config.json` declares integration names; Core Integration Registry holds the actual secrets. | -| 3 | **Self-mutation max_mutation_attempts = 1.** One PR per failure event, no exceptions. | -| 4 | **No autonomous writes** to ClickUp/Zoho/Odoo until Action Broker + authority tiers are live (Phase 4). | -| 5 | **Git is the single source of truth** for all agent artefacts. All changes flow through PRs with eval gates. | -| 6 | **MAF (Microsoft Agent Framework) is the sole agent execution runtime** — for all event-driven, webhook-triggered, multi-agent workflows, AND interactive operator chat (via AG-UI endpoint). The Copilot SDK is used only for self-mutation containers (`acb-mutation-runner`). No LangGraph. No deepagents. No n8n. No `copilot_chat.py` SSE path. | -| 7 | **No Theia / browser IDE.** That scope was explicitly cut. | -| 8 | **Source systems are authoritative.** CommandCenter is a read-mostly mirror with approval-gated writes. | - ---- - -## Architecture Note: Single MAF Runtime (interactive + background unified) - -CommandCenter uses **one execution runtime: MAF**. Interactive chat (via AG-UI endpoint) and background event-driven agents use the same MAF agents. The GitHub Copilot SDK is used only inside mutation containers. - -| | MAF (unified runtime — background + interactive chat) | Copilot SDK (mutation container only) | -|---|---|---| -| **Triggered by** | Webhooks, cron, ambient events; interactive chat (AG-UI) | Self-mutation errors only (`Self_Mutation_Node` spawns container) | -| **Entry point** | `POST /agent/run`, `POST /agent/webhook/{source}`, `POST /copilot/chat` (AG-UI) | Spawned via `docker run --rm -d` by `Self_Mutation_Node` | -| **Agent definition** | `agents.py` + `config.json` in agent repo — exports `build_agents() → list[Agent]` | `AGENTS.md` in mounted repo clone + `mutation_runner.py` prompt | -| **Credentials** | Integration Registry → `mcp_servers=` config in `GitHubCopilotAgent` | BYOK via LiteLLM env var in container; no Integration Registry access | -| **LiteLLM path** | Always (all model calls + MAF LiteLLM client) | Yes (BYOK mode forced) | -| **Durable state** | MAF native workflow engine (in-process asyncio, Phase 0); DurableTask (Phase 2) — HITL via Action Broker (Postgres `approval_queue`) | None (container self-destroys after run) | -| **Multi-agent** | `HandoffBuilder` (triage→specialist), `ConcurrentBuilder` (fan-out), `GroupChatBuilder` | N/A | - -### Resolved / Current State - -1. **AG-UI wiring (WBS 0.6)** — ✅ Done. `add_agent_framework_fastapi_endpoint(app, agent, "/copilot/chat")` is wired in gateway startup (`apps/gateway/gateway/main.py`). (CopilotKit was removed in M2.5; the Control Plane chat now consumes AG-UI over SSE via `api/agent/chat/route.ts`.) - -2. **Webhook → MAF dispatch (WBS 0.7)** — ✅ Done. `agent.py` dispatches webhook events to the MAF executor (`orchestrator.executor.run_agent`). The old Copilot-runtime dispatch arm (`runtime: copilot`) has been removed. - -3. **Observability** — the `langfuse` Python package is not installed and no OTLP exporter is wired; a Langfuse **container** exists in `infra/docker-compose.yml` but is **opt-in behind `--profile obs` and dormant**. Real telemetry today is the bespoke Redis activity/cost feed (`acb_common/activity.py`). Standing up distributed tracing is tracked as **BO-5** (audit H9). - -4. **Self_Mutation_Node** — implemented as a standalone async module (`apps/orchestrator/orchestrator/mutation.py`); no LangGraph. Formalising it as a MAF workflow step is pending (WBS 1.1). +The authoritative list is **root `AGENTS.md` → Global Constraints (1–11)** — read it there; this index does not duplicate it. Headlines only: no in-app code editing (Workflows app is the sanctioned config-only exception) · no credentials in agent/skill repos · self-mutation = 1 attempt, monorepo targeting is MT-0b-gated · no autonomous source-system writes outside the Action Broker path · git is the source of truth · MAF is the sole agent runtime (Copilot SDK = chat tier + mutation sandbox only) · no Theia · source systems authoritative · new execution features default to MAF · auth by construction + the eleven `user_management_contract.md` rules · **multi-tenancy is `organization_id` + RLS (D15), R5 binds every PR tenant-ready, and no agent ever gets a raw-SQL tool or a database connection**. --- @@ -186,31 +117,29 @@ CommandCenter uses **one execution runtime: MAF**. Interactive chat (via AG-UI e | Term | Meaning | |---|---| -| **Core Engine** | The CommandCenter FastAPI server + MAF workflow engine + Dynamic Agent Loader. Lives in `CommandCenter-Core`. | -| **Dynamic Agent Loader** | Python module that `git pull`s the target agent repo and `importlib`-imports `agents.py` at runtime, calling `build_agents()` to get MAF `Agent` instances. See `packages/acb_skills/acb_skills/loader.py`. | -| **Agent repo** | A GitHub repo named `agent-` containing `config.json`, `agents.py`, `instructions.md`. No credentials, no skill implementations. `agents.py` exports `build_agents() → list[Agent]` where each `Agent` is a MAF `GitHubCopilotAgent` (or other MAF provider) with tools and MCP server config declared. | -| **Skill repo** | A GitHub repo named `skill-`, a pip-installable Python package with one well-typed entry function. Surfaced to agents either as a Python tool function or as an MCP server. | -| **Integration Registry** | Core's encrypted Postgres store of all integration credentials. Admin-managed via Control Plane. | -| **AgentContext** | The MAF orchestration context; agents read credentials from MCP server config resolved from the Integration Registry. Replaces the former LangGraph `state["integrations"]` pattern. | -| **Self_Mutation_Node** | Implemented as a standalone async module (`apps/orchestrator/orchestrator/mutation.py`); no LangGraph. Formalisation as a MAF workflow step is pending (WBS 1.1). Spawns an isolated Copilot SDK mutation container (`acb-mutation-runner` Docker image), reads failure telemetry, applies a code fix to the live clone, and opens a GitHub PR. The container receives the mutation prompt and LiteLLM BYOK credentials via env vars; the agent repo is mounted at `/workspace/repo`. | -| **Hot-patch model** | Fix is applied to the live persistent clone immediately (recovery in minutes). The PR is the audit record + rollback trigger (close = auto rollback). | -| **Control Plane** | Next.js browser UI at `workbench/control_plane/`. Provides chat and HITL approval queue. Not an editor. | -| **Action Broker** | The *intended* single write path to source systems (ClickUp/Zoho/Odoo), enforcing per-action authority tiers. Lives at `apps/action_broker/`. **Current reality:** the authority-tier decision core exists but ships with **zero handlers and is not yet wired into the write path** — real ClickUp/email writes bypass it today. Wiring it is **BO-1** (P0). | -| **Reconciler** | Nightly agent that diffs entity graph vs source systems and escalates drift. Lives at `apps/reconciler/`. | -| **HITL** | Human-in-the-loop. Approval requests delivered via Control Plane or email/WhatsApp when operator is not at the UI. | -| **authority tier** | read / suggest / suggest+apply / autonomous — the allowed scope of an agent's action on a specific resource type. | -| **Annealer** | Phase 5 sub-agent that mines successful run patterns, proposes new reusable skills as PRs, and manages shadow → canary → full rollout. **Reference implementation:** Hermes Agent's "Curator" (auto-authors + prunes skills on a cycle) — see CH-7 in [`specs/competitive_hardening_2026-07.md`](specs/competitive_hardening_2026-07.md). Our differentiator is that skill proposals go through the human PR/approval gate; self-improvement *plus* enterprise HITL is something neither Hermes nor OpenClaw offers. | +| **Core Engine** | The CommandCenter FastAPI gateway + MAF workflow engine + Dynamic Agent Loader. | +| **Dynamic Agent Loader** | `packages/acb_skills/acb_skills/loader.py` — pulls/imports `agents.py` at runtime, calling `build_agents()`. | +| **Agent repo** | `agent-` repo (or `apps/agents/*`): `config.json`, `agents.py`, `instructions.md`. No credentials, no skill implementations. | +| **Skill repo** | `skill-` pip-installable package with one well-typed entry function, surfaced as a tool or MCP server. | +| **Integration Registry** | Encrypted Postgres store of integration credentials, admin-managed. Per-org from migration 158 (MT-0d). | +| **Self_Mutation_Node** | `apps/orchestrator/orchestrator/mutation.py` — spawns the isolated mutation container, applies a tested fix, opens a PR. Monorepo targeting is gated by MT-0b (`organization.first_party`). | +| **Hot-patch model** | Fix applied to the live clone immediately; the PR is audit record + rollback trigger. | +| **Control Plane** | Next.js UI at `workbench/control_plane/`. Chat + HITL approvals. Not an editor. | +| **Action Broker** | The single write path to source systems (`apps/action_broker/`), enforcing authority tiers. **Live since 2026-07-13**: handlers register at six sites (ClickUp, WhatsApp, workflow, app-publish, `crm.zoho_*`); `ACTION_BROKER_ENFORCE` ships OFF (audit-and-chokepoint posture) — the flip is owner-gated behind BO-1a+BO-1b (work_plan.md WS-1). | +| **Reconciler** | Nightly drift-diff agent at `apps/reconciler/`. | +| **HITL** | Human-in-the-loop approvals via Control Plane (or email/WhatsApp). | +| **authority tier** | read / suggest / suggest+apply / autonomous — allowed scope of an agent's action on a resource type. | +| **Tenant (D15)** | An `organization_id` row isolated by FORCE ROW LEVEL SECURITY bound at the connection seam. A deployment is a *placement* (priced tier), never the boundary. | +| **Center** | An `org_group` projection of the one platform inside a tenant — never a tenant, never a separate deployment (`department_centers.md`). | +| **Annealer** | Phase-5 skill-mining sub-agent concept (CH-7 reference: Hermes "Curator"); proposals go through the human PR gate. | --- ## Current Phase -Phases 0, 1, 1.5, 1.6 are complete and **M2 is closed**. A **foundation architecture audit (2026-07)** is now the active workstream — it found the platform's documented guarantees are materially ahead of what the code enforces, and its P0 items gate the feature roadmap. **Two backlogs, read both:** -- **Foundation hardening (do first):** [`/FOUNDATION_BUILDOUT_CHECKLIST.md`](../FOUNDATION_BUILDOUT_CHECKLIST.md) — `BO-1..21` (+ `CH-*` in `specs/competitive_hardening_2026-07.md`, `HH-*` in `specs/harness_hardening_2026-07.md`). -- **Feature roadmap:** [`project_plan.md`](project_plan.md) §6 — M2.9 email → M3 agent ecosystem → M4 capture → M5 write authority → M6 intelligence. +**The dispatch board (`work_plan.md` §2) is the only current-state authority.** As of 2026-08-09: -**Immediate priorities (P0 foundation first):** -- **BO-8** rotate/purge committed secrets · **BO-2** enforce auth (never-reject → require) · **BO-1** wire the Action Broker into the write path (non-negotiable #4 is currently false) · **BO-3** mutation governance residuals. -- **SEC-1 / R-06** — lock down public Postgres/Redis (5432/6379) on the VPS (bind `127.0.0.1`). -- Then P1: **BO-7** sandbox, **BO-5** observability+cost, **BO-20** event-bus consumer + job queue, **BO-6** migrations. -- Feature track (in parallel where unblocked): M2.9 email residuals; Phase 2 Zoho/Gmail ingestion (2.1/2.2) + entity resolution (2.3); Phase 1 cleanup (PR automation 1.3, BYOK metering 1.7). +- **WS-29 multi-tenancy** is in execution: Phase 0 built; H1 (migrations 157–159) scratch-verified with the prod apply riding **PR #404** (owner's merge); H2 — converting 561 session call sites — is the long pole and dispatches after the H1 gate. MT-2/MT-3 pricing inputs were answered 2026-08-09 (D18). +- **App workstreams run in parallel under R5** (tenant-ready by construction — owner call D18): CRM (WS-26) a–g live with autolead PR #403 open; Projects (WS-27) a–n merged; People (WS-28) a+b live; Email/Tasks/Calendar/Notes/WhatsApp per their rows. +- **Foundation**: broker enforce flip waits on BO-1a/1b; secrets purge+rotation (WS-2/BO-8) remains the standing P0; backups scheduled + restore-verified (BO-23); deploys recovered 2026-08-06/07 UTC with one open health-verify failure at tip (WS-25). +- **Owner-gated queue** (work_plan.md §6): PR #404 merge (H1), G1/G2 onboarding gates, enforcement flips, and the WS-26e/WS-27g cutovers. diff --git a/ai-company-brain/agent_repo_compatibility.md b/ai-company-brain/agent_repo_compatibility.md index c6db1a699..9ffcc85ef 100644 --- a/ai-company-brain/agent_repo_compatibility.md +++ b/ai-company-brain/agent_repo_compatibility.md @@ -1,5 +1,7 @@ # Agent Builder Guide — CommandCenter Framework +> ⚠️ **Superseded premise (banner added 2026-08-09).** This guide (2026-06-19) describes the distributed agent-repo model; `specs/agent_architecture.md` supersedes that framing (single runtime, manifests + agent_defs). Use this file only for maintaining EXISTING agent repos; new agents follow agent_architecture.md. + > **Audience:** AI coding agents and developers building new CommandCenter-compatible agents. > **Reference implementation:** `sales-prospector` repo — the canonical pattern every new agent must follow. > **Framework:** DOE v2 — Skills (what to do) / Orchestration (decision making) / Execution (doing the work). diff --git a/ai-company-brain/agents-workspaces-artifacts.md b/ai-company-brain/agents-workspaces-artifacts.md index ac8e76e0b..1e3f3f761 100644 --- a/ai-company-brain/agents-workspaces-artifacts.md +++ b/ai-company-brain/agents-workspaces-artifacts.md @@ -1,5 +1,7 @@ # Agents, Workspaces, Files & Artifacts — How It All Connects +> **Status:** living reference (workspace/artifact model) · no owning board row · **Last reviewed:** 2026-08-09 (D15 phrasing sweep only — content not re-verified against code) + Definitive reference for how agents are registered, loaded, where their files live on disk, and how the chat Files panel and the Artifacts viewer surface them. Written after a full end-to-end review (backend + frontend + live VPS). @@ -11,7 +13,7 @@ them. Written after a full end-to-end review (backend + frontend + live VPS). > [`specs/agent_persistence_implementation.md`](specs/agent_persistence_implementation.md) > is the engineering reference (every function, table, and seam — read before > changing how persistence works). Required reading before building the -> in-platform agent workbench or any new MAF agent (this deployment or a future second tenant deployment). This doc explains the *layout*; the framework explains the *contract*; +> in-platform agent workbench or any new MAF agent (this deployment or a future second tenant deployment *(2026-08-09, under D15: another organization — rows + RLS, not a deployment)*). This doc explains the *layout*; the framework explains the *contract*; > the implementation reference explains *how it's built*. > > **The disk workspace described below is now a rehydratable cache** — the three diff --git a/ai-company-brain/project_plan.md b/ai-company-brain/project_plan.md index 6eff85b3a..356dea143 100644 --- a/ai-company-brain/project_plan.md +++ b/ai-company-brain/project_plan.md @@ -3,7 +3,7 @@ > **Org:** Fracktal Works · **Updated:** 2026-08-01 · **Version:** 3.1 > Single source of truth for **what** we build (requirements), **when** (milestones), and **how much** (phased WBS). Absorbs the former `product_requirements.md` and `wbs.md`. > **Read first:** [`AGENTS.md`](AGENTS.md) — current build status, file index, glossary. -> **Near-term sequencing and dispatch:** [`work_plan.md`](work_plan.md) (2026-07-31) — for ordering, that doc wins. +> **Near-term sequencing and dispatch:** [`work_plan.md`](work_plan.md) (2026-07-31) — for ordering, that doc wins. *(re-affirmed 2026-08-09: §6 sequencing yields to work_plan.md §2, which now also carries WS-29 multi-tenancy — D15/D16/D18)* > **Companions:** [`system_architecture.md`](system_architecture.md) (design + ADRs) · [`reference.md`](reference.md) (MAF / Copilot SDK / memory library notes) · [`agent_repo_compatibility.md`](agent_repo_compatibility.md) (how to build an agent) · [`specs/`](specs/) (per-feature specs). > **⚠️ Two work surfaces — this doc is the FEATURE roadmap (M3→M6). It is NOT the whole "what's left":** diff --git a/ai-company-brain/reference.md b/ai-company-brain/reference.md index 121963cc5..9e2dc8d2e 100644 --- a/ai-company-brain/reference.md +++ b/ai-company-brain/reference.md @@ -2,6 +2,7 @@ > Consolidated reference for the runtime libraries and memory design CommandCenter depends on. Consult when implementing orchestration, Copilot agent wrappers, or memory wiring. For *why* decisions were made see [`system_architecture.md`](system_architecture.md) (ADRs); for *what/when* see [`project_plan.md`](project_plan.md). > Last verified 2026-06-04; versions updated 2026-06-10. (Rewritten 2026-06-20 from the former `ref_maf.md` / `ref_copilot_sdk.md` / `ref_memory_architecture.md`, whose source bytes were corrupted.) +> ⚠️ **Stale-warning 2026-08-09:** last verified 2026-06-04 — pins may lag `uv.lock` (e.g. agent-framework-core 1.8.1 is live per multi_agent_orchestration.md). `uv.lock` is the source of truth for versions; re-verify any claim here before relying on it. **Contents:** [1. MAF](#1-microsoft-agent-framework-maf) · [2. GitHub Copilot SDK](#2-github-copilot-sdk) · [3. Memory architecture](#3-memory-architecture) diff --git a/ai-company-brain/specs/agent_architecture.md b/ai-company-brain/specs/agent_architecture.md index 1d9d5eff7..4794d2ca7 100644 --- a/ai-company-brain/specs/agent_architecture.md +++ b/ai-company-brain/specs/agent_architecture.md @@ -720,7 +720,9 @@ so the shape is visible; nobody should be sent at them until they are ticketed t agent's KB lives) — that is a decision this spec does not record, not a build. **On Phase C's priority.** Command Center is an **internal Fracktal tool**: the team uses it, -there are no external tenants. So the Agent Workshop's describe-to-create flow is about +there are no external tenants. *[Premise dated 2026-08-09: true until the first external +tenant — D15/WS-29; the Workshop's bounded-by-headcount value re-opens at MT-2.]* So the +Agent Workshop's describe-to-create flow is about letting *colleagues* create agents, not about a public product surface. Its value is real but bounded by headcount, and it is the largest unspecced surface in this document (§12.2 WS-8n is a spec ticket, not a build ticket). **It should not outrank A0/A1/A/B**, all of which @@ -1006,3 +1008,15 @@ instancing already ships from `config.json` via `AgentManifest.instance_key()` ( Question 4 is worth answering early — if declarative agents don't need container isolation, the sandboxing roadmap shrinks to the two code agents plus the mutation sandbox. + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-8 — **Agent architecture A0→C** (single runtime, manifests + `agent_defs`, generic declarative builder, Agent Workshop describe-to-create) +**State cell (as of the move):** 🟡 +**Narrative (verbatim):** A0's `approve_all` half done 2026-07-26. ~~"three states in one doc, see §5"~~ **repaired 2026-08-03** — §5 doc-remediation item 14 is closed (one A0 status; the F/G dependency split is written). **~60% of Phases A+B is unwired substrate — read §12.1 before dispatching anything from this row**, or an implementer will rebuild `manifest.py` / `declarative.py`, both of which are complete, documented and tested with zero production callers. ~~"Phase A unblocks D3's long-term form"~~ **struck — verified false in the direction that matters:** `config.json`-based instancing already ships via `AgentManifest.instance_key()` (`manifest.py:235`, live at `executor.py:917-937` and `routes/workspace.py:247-256`, with a `sharing` block on all six first-party agents), so **WS-14 is NOT waiting on WS-8 Phase A** (§12.5). D7's MAF-side MCP gap is now a ticket here — **WS-8c**. + +**Corrections applied 2026-08-09:** current as moved. diff --git a/ai-company-brain/specs/agent_file_and_memory_framework.md b/ai-company-brain/specs/agent_file_and_memory_framework.md index b87509b5d..7daf1d907 100644 --- a/ai-company-brain/specs/agent_file_and_memory_framework.md +++ b/ai-company-brain/specs/agent_file_and_memory_framework.md @@ -3,7 +3,7 @@ **Status:** Part 1 (native-MAF mutation → monorepo PR) and Part 2 (files/memory → Postgres blob store) built 2026-07-15. This doc is the canonical contract for how agent code, files, and memory persist — and the required reading before we build -the in-platform **agent-building workbench** or any new MAF agent (here or on a future second tenant deployment). +the in-platform **agent-building workbench** or any new MAF agent (here or on a future second tenant deployment *(2026-08-09, under D15: another organization, not another deployment)*). **Companions:** `agent_persistence_implementation.md` (the engineering reference — every function, table, and seam; **read this before changing how persistence works**), @@ -108,7 +108,10 @@ a rehydratable cache. (`create`/`modify`/`delete`/`promote`), actor, run/session provenance. - **Store module:** `acb_memory/blob_store.py` — `put_file / get_file / list_files / delete_file / file_history / rehydrate_workspace`. Keyed by `agent_name` only - (the sole tenant key → portable to a second tenant deployment unchanged). Graceful: DB down → + (⚠️ **this was the sole tenant key under D11 and is no longer sufficient** — D15 + makes the tenant an `organization_id` row, so `agent_blob` gains that column plus RLS in + **MT-1b** and its content moves to object storage in **MT-1g**; + `saas_multitenancy_implementation.md` §1 holds the shapes). Graceful: DB down → no-op, agents keep working off disk. - **Write-through** at every write path (disk write + store mirror + history row): - Agent-side: `write_artifact` and `save_note` → `mirror_to_blob_store(...)`. @@ -210,11 +213,12 @@ than VS Code + Git), it MUST preserve every invariant here. Considerations: --- -## 6. Second-tenant portability + the production mutation gap +## 6. Second-tenant portability + the production mutation gap *(phrasing predates D15 — read 'second tenant' as 'another organization', whose isolation is rows + RLS, not a deployment; a dedicated deployment survives only as a priced placement)* **Portability:** the blob store, three-folder contract, and memory scopes are all keyed on `agent_name` with no CommandCenter-specific coupling, so MAF agents built -on a second tenant deployment use the identical mechanism. When we stand up +on a second tenant deployment *(2026-08-09, under D15: another organization, not another +deployment)* use the identical mechanism. When we stand up agents there, they must adopt this framework verbatim — same tables, same tools, same folders. Do not fork the storage model per platform. @@ -223,7 +227,8 @@ agent's approved self-mutation opens a PR against the **shared CommandCenter monorepo**. That is fine only while all agents are first-party and Command Center is WIP. For multi-tenant / customer agents this is unacceptable — third parties must never push to the shared monorepo. This must be replaced (per-tenant -repo, or a tenant-scoped store the loader reads at runtime) before production. Full +repo, or a tenant-scoped store the loader reads at runtime) before production +*(ticketed as MT-0b, built 2026-08-08 pending review)*. Full detail: `docs/DESIGN_LIMITATION_native_maf_mutation.md`. --- @@ -237,7 +242,8 @@ detail: `docs/DESIGN_LIMITATION_native_maf_mutation.md`. - [ ] Code changes flow to git via a reviewed PR — never the blob store. - [ ] `agent_name` is unique and stable (it's the storage + memory + mutation key). - [ ] For workbench / multi-tenant work: the mutation target is tenant-isolated - (NOT the shared monorepo) before any multi-tenant deployment. + (NOT the shared monorepo) before any multi-tenant deployment. *(ticketed as + MT-0b, built 2026-08-08 pending review)* --- diff --git a/ai-company-brain/specs/agent_persistence_implementation.md b/ai-company-brain/specs/agent_persistence_implementation.md index effc598bd..3104d88f8 100644 --- a/ai-company-brain/specs/agent_persistence_implementation.md +++ b/ai-company-brain/specs/agent_persistence_implementation.md @@ -1,6 +1,6 @@ # Agent Persistence — Implementation Reference (how it's built, so you can change it) -**Status:** built 2026-07-15 (Part 2). Live once PR #60 merges to `main` (migrations +**Status:** built 2026-07-15 (Part 2). **Live** (PR #60 merged; header corrected 2026-08-09) (migrations 70 + 71 auto-apply on deploy). This is the **engineering companion** to `agent_file_and_memory_framework.md` — that doc is the *contract* (what agents must do); this doc is the *implementation* (every function, table, and seam), so we can @@ -19,7 +19,11 @@ stored in Postgres (the **source of truth**) with the on-disk workspace as a append a version-history row (**write-through**). On agent load the workspace is **rehydrated** from Postgres. A read that misses on disk is **faulted-in** from Postgres. Everything is keyed on `agent_name` alone, so it ports to any platform -(a second tenant deployment) unchanged. If Postgres is unavailable, every store call is a no-op +unchanged. ⚠️ **Under D15 (`saas_multitenancy.md` §1) `agent_name` alone is no longer a +sufficient key** — it was, while one deployment meant one tenant; pooled tenancy makes it +ambiguous across organizations. `agent_blob` gains `organization_id` + RLS in **MT-1b**, +and the blob content itself moves to object storage in **MT-1g**. The phrase "a second +tenant deployment" is retained nowhere in this spec because a second tenant is now a row. If Postgres is unavailable, every store call is a no-op and agents keep working off the disk cache. ``` diff --git a/ai-company-brain/specs/agent_platform_hardening_2026-07.md b/ai-company-brain/specs/agent_platform_hardening_2026-07.md index 70b87cb69..3fe0276d8 100644 --- a/ai-company-brain/specs/agent_platform_hardening_2026-07.md +++ b/ai-company-brain/specs/agent_platform_hardening_2026-07.md @@ -28,6 +28,11 @@ > The ladder must hold up to **trusted colleagues, not hostile users**, which > moves T2 from "before the Agent Workshop opens" to a **deprioritised > sub-project**. See §1.3 and §1.5. +> *[⚠️ Premise re-scoped 2026-08-08/09 (D15/D16, WS-29): still true as a fact — +> no external tenant exists yet — but no longer the planning posture; +> CommandCenter is being prepared for sale. T2 stays parked, with a NEW +> trigger: it is a precondition of the §5.1 pooled cutover +> (`saas_multitenancy.md`, MT-0c-2), not "a second org". See the §1.5 banner.]* **Reviews:** [`agent_architecture.md`](agent_architecture.md) · @@ -119,7 +124,9 @@ a trigger, so it is struck from the table above. To restore it, something must f to read: the minimum is an `AgentManifest` provenance field (e.g. `provenance: first_party | creator | external`) derived from the registration path and persisted on the agent registry row — **that is a design task with no owner and no acceptance today**, and -under the internal-tool threat model (§1.5) it is not needed. +under the internal-tool threat model (§1.5) it is not needed *(see §1.5's D16 update, +2026-08-09 — MT-0b's `organization.first_party` (migration 157) is now the provenance +field's first incarnation)*. ### 1.3 What to build, and when — *reconciled against code 2026-08-03* @@ -149,7 +156,7 @@ hardening; there are now two (`mutation.py`, `copilot_sandbox.py`), both carryin caps/limits since 2026-07-27 and **neither** passing `--network` or `--read-only`. Acceptance is **WS-3b** in `permissions_sandbox_b6.md` §P5-b. -**~~Before the Agent Workshop opens to non-engineers.~~ → deprioritised; see §1.5.** +**~~Before the Agent Workshop opens to non-engineers.~~ → deprioritised; see §1.5** *(and its D16 update, 2026-08-09)*. The original trigger assumed the Workshop would put agent authorship in the hands of people outside the engineering team. Under the 2026-08-03 owner decision that is not the near-term shape of this product: the Workshop's users are **Fracktal colleagues**, and a colleague who @@ -159,8 +166,12 @@ at that boundary. **Before multi-tenant (a second org on this platform).** This remains the real T2 trigger, and it is the *only* one left. The trust boundary genuinely moves from "our team" to "someone else entirely", and at that point `DESIGN_LIMITATION_native_maf_mutation.md` must -also be closed — though the declarative model already removes it for the majority case. No -second org is planned; **T2 is parked until one is** (§1.5). *(The original wording made T2 +also be closed — though the declarative model already removes it for the majority case +*(closed as MT-0b, built 2026-08-08 pending review)*. ~~No +second org is planned; **T2 is parked until one is** (§1.5).~~ *[Re-taken 2026-08-08/09: +external orgs ARE planned (WS-29), and D16 narrows this trigger — silo tenants (customers +1–5, one per box) do not require T2; the **§5.1 pooled cutover does**. See the §1.5 +banner.]* *(The original wording made T2 mandatory for "every non-first-party agent regardless of tool surface" — kept as intent, but see §1.2: there is no field that says which agents those are, so this cannot be stated as acceptance until one exists.)* @@ -182,6 +193,16 @@ authority" — which is the failure mode this platform will actually hit. ### 1.5 T2 is a parked sub-project — the internal-tool threat model (owner decision, 2026-08-03) +> ⚠️ **Update 2026-08-09 — the premise below expired, the parking survives (D15/D16).** +> WS-29 (`saas_multitenancy.md`) retires "internal Fracktal tool, no external tenants" +> as the planning posture: CommandCenter is being prepared for sale. **D16 re-takes the +> un-park trigger**: T2 is now a **precondition of the §5.1 pooled cutover** (customer +> 8–12) — the silo phase survives on this section's reasoning (one tenant per box means +> an escaped agent reaches only data it already had), the pooled phase does not. The +> section below is retained as the record of the 2026-08-03 decision; its "no second +> org is planned" claims are historical. Acceptance still must not be written until the +> owner un-parks — that rule is unchanged. + **Do not delete the T2 material above; it is the right destination.** But it is not near-term work, and the reason is a threat-model correction rather than a change of mind about isolation: @@ -205,12 +226,15 @@ per-agent venv/image, warm pool — `permissions_sandbox_b6.md` §P5-c) keeps it loses its schedule. It has **no acceptance criteria and should not be given any** until one of these two things is true, at which point it is re-costed from scratch: -1. A **second organisation** runs on this platform (real multi-tenancy), or +1. A **second organisation** runs on this platform (real multi-tenancy) *(re-scoped by + D16, 2026-08-08: a silo tenant does not trigger it; the **pooled cutover** does — + `saas_multitenancy.md` §5.1 / MT-0c-2)*, or 2. Agent authorship opens to someone **outside Fracktal** — a customer, a contractor with no - monorepo access, or a public Agent Workshop. + monorepo access, or a public Agent Workshop *(unchanged by D16)*. **OWNER-GATE:** un-parking T2 is an owner decision, not an agent's. An agent asked to -"finish the isolation ladder" builds WS-3a and WS-3b and refuses T2 by name. +"finish the isolation ladder" builds WS-3a and WS-3b and refuses T2 by name. *(Unchanged +under D16 — `work_plan.md` §6's first blockquote is the registry entry.)* --- diff --git a/ai-company-brain/specs/calendar_ai_review.md b/ai-company-brain/specs/calendar_ai_review.md index fec916e44..11362adfd 100644 --- a/ai-company-brain/specs/calendar_ai_review.md +++ b/ai-company-brain/specs/calendar_ai_review.md @@ -1,5 +1,7 @@ # Calendar × Tasks × AI — comprehensive review +> **Status:** review record (2026-07-22) — findings since triaged into `calendar_focus_os.md` §9 / `calendar_timeboxing.md` §13, which own all acceptance. Cited by migration headers 92/97/100. Not re-verified since. *(Header added 2026-08-09.)* + Date: 2026-07-22 · branch `claude/calendar-productivity-redesign-rdh50k`. Scope: (1) the calendar app and its integration with the GTD task manager, (2) every place AI already runs, audited for prompt/context correctness, diff --git a/ai-company-brain/specs/calendar_focus_os.md b/ai-company-brain/specs/calendar_focus_os.md index b09bcb543..9053724d9 100644 --- a/ai-company-brain/specs/calendar_focus_os.md +++ b/ai-company-brain/specs/calendar_focus_os.md @@ -725,3 +725,17 @@ filter); `test_email_calendar_context.py` = the email-side calendar context; between focus_os and timeboxing is clean (§5 here is canonical for `gtd_time_blocks`; `calendar_timeboxing.md` §13 is canonical for P4); the other two docs are unregistered. + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-21 — **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) +**State cell (as of the move):** 🟡 partial +**Narrative (verbatim):** **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. + +**Corrections applied 2026-08-09:** +- current as moved +- Horizons ownership: WS-21 owns it per §4 of the board, still DO-NOT-DISPATCH (no acceptance). diff --git a/ai-company-brain/specs/calendar_ux_review.md b/ai-company-brain/specs/calendar_ux_review.md index af64a756b..c206ffb30 100644 --- a/ai-company-brain/specs/calendar_ux_review.md +++ b/ai-company-brain/specs/calendar_ux_review.md @@ -1,5 +1,7 @@ # Calendar-for-task-management — UX & backend review +> **Status:** designer's audit record (2026-07-18) of PR #71. Sole home of the block-reminders/notifications item (`calendar_focus_os.md` §9.13 points here); everything else since triaged into the two owning calendar specs. Many fixes it proposes HAVE landed — check `calendar_focus_os.md` §9 before citing a gap as open. *(Header added 2026-08-09.)* + A designer's-eye audit of the calendar built in PR #71 (2026-07-18). What's strong, what's missing, and the psychology of *why* people abandon calendars for task management — with prioritized, specific fixes. diff --git a/ai-company-brain/specs/chat_agent_framework_review_2026-07.md b/ai-company-brain/specs/chat_agent_framework_review_2026-07.md index 43a91037e..33aab762d 100644 --- a/ai-company-brain/specs/chat_agent_framework_review_2026-07.md +++ b/ai-company-brain/specs/chat_agent_framework_review_2026-07.md @@ -60,7 +60,10 @@ and `agents-workspaces-artifacts.md`. 7. **Chat-level HITL is genuinely strong** (parked futures, cross-worker Redis control bus, reconnect replay, both-runtime parity tests). **Write-path HITL is not real yet**: the - Action Broker ships with zero handlers and real ClickUp/email writes bypass it (BO-1), and + ~~Action Broker ships with zero handlers and real ClickUp/email writes bypass it (BO-1)~~ + *(corrected 2026-08-09: false since 2026-07-13 — the broker is live and wired at six + registration sites; `ACTION_BROKER_ENFORCE` ships OFF and email handlers remain BO-1c; + see `work_plan.md` WS-1)*, and `require_internal_auth` fails open with no token configured (BO-2). These remain the correct P0s. @@ -242,6 +245,7 @@ cards in chat. Two gaps: workspace `.gitignore` protects deliverables from `git reset --hard`). Keep it; the only pending item is the known DEV-ONLY mutation-remote limitation (`docs/DESIGN_LIMITATION_native_maf_mutation.md`) before any multi-tenant use. + *(ticketed as MT-0b — built 2026-08-08 pending review)* --- diff --git a/ai-company-brain/specs/chat_ux.md b/ai-company-brain/specs/chat_ux.md index c4bd939a2..8cd75a3d5 100644 --- a/ai-company-brain/specs/chat_ux.md +++ b/ai-company-brain/specs/chat_ux.md @@ -2,7 +2,7 @@ > **Type:** Implementation Spec > **Date:** 2026-06-05 -> **Status:** Active — Phase 1 complete, Phase 2 (CopilotKit patterns) in progress +> **Status:** Active — Phase 1 complete, Phase 2 (CopilotKit patterns) in progress · **Header re-dated 2026-08-09:** §12.3 is superseded by `generative_ui_2.md` §2; the live remainder is §12 items **V, VII–XI** plus §11 **H** (dev console) and **J** (push-to-talk) — the 2026-08-01 doc-truth note below is authoritative; the body is retained as protocol reference per `work_plan.md` §5 item 3 > **Target:** `workbench/control_plane/src/` — chat tab and related components > **Reference:** VS Code source — `chatThinkingContentPart.ts`, `chatProgressContentPart.ts`, `chatSubagentContentPart.ts`, `chatToolInputOutputContentPart.ts` (read in full); GitHub Copilot Chat extension; Claude Code > **Source studied:** [microsoft/vscode](https://github.com/microsoft/vscode) `src/vs/workbench/contrib/chat/browser/widget/chatContentParts/` diff --git a/ai-company-brain/specs/colleague_onboarding.md b/ai-company-brain/specs/colleague_onboarding.md index 003680902..90f6abab6 100644 --- a/ai-company-brain/specs/colleague_onboarding.md +++ b/ai-company-brain/specs/colleague_onboarding.md @@ -1,11 +1,14 @@ # Colleague onboarding — the readiness gate, the runbook, and the capability matrix -**Status:** 🔴 NOT READY — **three** blocking gates still open (§1.1). **G4 is +**Status:** 🔴 NOT READY — **2 gates + 1 decision open** (G1 Caddy strip · +G2 GATEWAY_INTERNAL_TOKEN · N5 notes-routes decision) — G3 backups CLOSED 2026-08-07; +updated 2026-08-09. **G4 is CLOSED: all four owner-scoping tickets shipped 2026-08-04** — N4 (Tasks people directory: directory open, HR fields restricted, writes admin-only) and N1–N3 (Notes: sixteen routes in six files, single-item approve/reject, and the -bot_join recording hijack). **G1, G2 and G3 are unchanged**, two of them are -OWNER-GATE, and **it is still not safe to invite anybody.** G4 closed the holes +bot_join recording hijack). **G1 and G2 remain open** (G3 closed 2026-08-07 — +backups scheduled + restore-verified, see BO-23), both OWNER-GATE, plus the N5 +decision — and **it is still not safe to invite anybody.** G4 closed the holes that survive a *correct* identity; G1/G2 are about the identity itself, and an owner predicate applied to a forged one is not a control. §4 also mints **N5** (the rest of `routes/notes` — nine modules N1's table did not enumerate), @@ -100,12 +103,17 @@ guessing. If a criterion changes here, change it there in the same PR. **Scope.** This doc owns the *gate* and the *matrix*. It does not own the access model (`org_access_control.md`), the visibility doctrine -(`tenancy_and_visibility.md` — D11/D12), or the Centers IA -(`department_centers.md`). Where they disagree with a cell here, re-measure and +(`tenancy_and_visibility.md` **§3–§5 / D12** — still binding), the **tenancy +boundary** (`saas_multitenancy.md` **§1 / D15**, which re-took D11 on 2026-08-08), +or the Centers IA (`department_centers.md`). Where they disagree with a cell here, re-measure and fix whichever is stale. **Non-goals.** Not an HR onboarding process. Not a rollout plan for a second -tenant (D11: the tenant boundary is the deployment). Not a fix for §4's open +tenant — that is **`saas_multitenancy.md` §5.1 / WS-29** now. ⚠️ **The old text +here cited D11 ("the tenant boundary is the deployment"), which was re-taken as +**D15** on 2026-08-08: a tenant is an `organization_id` row. This doc's gate is +about **colleague #1 inside one org** and is unaffected — but do not cite D11 +from here. Not a fix for §4's open holes — this is the gate that says they must be fixed, and sizes them. > **Two facts in this doc are OWNER-REPORTED, not measured.** *"Exactly one @@ -180,7 +188,7 @@ the owner loses work that has no backup. | Item | Why it does not block | |---|---| | Workflows are org-wide | A recorded v1 decision (`routes/workflows/crud.py:1-5`, spec Q3). It is not a defect; it is a **consequence of granting `feature:workflows`** — see §3.4. `member` does not hold it. | -| `main` has no branch protection | `work_plan.md` §2 exception 1. Real, OWNER-GATE, and about the repo rather than about who can read whose mail. | +| ~~`main` has no branch protection~~ **CLOSED 2026-08-03** *(row corrected 2026-08-09)* | `work_plan.md` §2 exceptions row 1: protection enabled with `enforce_admins: true`; `required_status_checks` deliberately `null` (docs-only PRs run zero checks). | | Custom-App grants do not honour `group:` | WS-14; `routes/apps/grants.py:68-85`. Narrower access than intended, not wider. | --- @@ -967,8 +975,9 @@ one user and live the moment a colleague signed in. **These are the gate's G4. They were sized here and built elsewhere — this document does not fix them.** All four were 🟢 **AGENT-SAFE**. -> ⚠️ **G4 closing does not make WS-24 green.** G1, G2 and G3 are untouched and -> two of them are OWNER-GATE. **It is still not safe to invite anybody.** +> ⚠️ **G4 closing does not make WS-24 green.** G1 and G2 are untouched and +> OWNER-GATE *(G3 closed 2026-08-07 — noted 2026-08-09)*. **It is still not safe +> to invite anybody.** > Specifically: without G1 the reverse proxy does not strip inbound > `X-User-Email` / `X-User-Role`, and without G2 the service identity may still > be the LLM key every agent holds — an identity forgery reaches *any* member's @@ -1965,3 +1974,20 @@ byte-identical by sha256 — `250ab021…`): | `APPROVE_MATRIX.get(status, "refuse")` → `"provision"` | **1 failed**, on the "came from somewhere further down" assertion — the weak first version of the same test **survived** this | `tests/unit/test_signin_requests.py`: **50 → 52 passed.** + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-24 — **Colleague onboarding readiness** — the gate, the runbook, and the capability matrix *(minted 2026-08-04)* +**State cell (as of the move):** 🔴 **NOT READY — but the shape changed on 2026-08-05: every AGENT-SAFE item is now BUILT, MERGED and DEPLOYED, and what remains is two owner actions on the identity boundary plus two on backups.** `main` @ `74082882` is live on the box: migration 143 applied (`access_request` exists), both services active, and the first real backup this deployment has ever taken landed at `/opt/acb/backups/2026-08-05T044202Z` (22 MB data dump) because #347's pre-migration gate fired. **N6a** (sign-in queue), **N7** (self-lockout guards on three doors + a Remove control), **N8** (hard delete) and the **OAuth connect-flow P0** all shipped. ⚠️ **Two findings measured against the running deployment, both OWNER-GATE, both in §6:** `GATEWAY_INTERNAL_TOKEN` is **byte-identical** to `LITELLM_MASTER_KEY` (same sha256), and gateway `:8080` + workbench `:3001` answer from the public internet, so Caddy's identity strip can be walked around. Until both are closed, every owner predicate in this plan is applied to an identity that can be forged. **The build rules an app must not deviate from now live in `specs/user_management_contract.md`** (§4 registry). Historical state below. ✅ G4 CLOSED 2026-08-04 — all FOUR tickets shipped:** N4 (`ws-24-n4-people-scoping`) the Tasks people directory is *directory open, HR fields restricted* with all four writes on `admin:members:manage`; **N1–N3** (`ws-24-n1n3-notes-scoping`) the Notes owner-scoping remainder. **G1/G2/G3 unchanged — inviting anybody is still unsafe.** **✅ N6a BUILT + REPAIRED 2026-08-04** (`ws-24-n6-signin-requests`, spec §6) — the sign-in queue: migration 143 `access_request`, `resolve_access(record_request=)` gated to the request path only, `GET/POST /admin/members/requests…`, a Requests tab, and `invited` rows now labelled "never signed in". ⚠️ **A same-day adversarial review found a P1 cross-gate escalation** — approve could reinstate an off-boarded member on the weaker `admin:members:invite`, because a decided `access_request` row outlives the decision and the `ON CONFLICT` guard matched `removed`; and the test that claimed to fence it was a Python mirror of the same SQL, so the exact mutation passed all 28 cases. Both fixed: a decided request cannot be re-decided, provisioning never activates a row that is not `invited`, and the fence is now a structural assertion against the statement string. ⚠️ **A SECOND pass then found the half that fix left open, and it was the more damaging one:** the provisioning guard declines *silently*, so approve still returned **200**, still marked the request `approved`, and still re-granted `['member']` to the off-boarded member — which removed the still-locked-out person from a tab that renders only `pending`, permanently, since the resolver's upsert never rewrites `status`. **That is the 53-knock incident recreated by its own fix.** Closed by `APPROVE_MATRIX` (spec §6 *Repair round 2*), read before anything is written: absent/`invited` → provision; `active` → leave their roles alone and say so; `suspended`/`removed` → **409 and the request stays `pending`**. The invariant now stated and fenced: **approve never rewrites the roles of a member who already exists in a state other than `invited`** — the same defect demoted a live `admin` to `member` on `admin:members:invite`. `_DECIDE_SQL` also binds the read's status filter into the write, so a lost race discards its own provisioning. ⚠️ **A THIRD pass found the same shape once more — a race this time, not a sequence — and with it the reason it kept recurring.** `APPROVE_MATRIX` is read *before* the write, which closes the sequential holes but not the concurrent one: `_PROVISION_MEMBER_SQL`'s `CASE` arms are re-evaluated by Postgres against the latest **committed** row, so a second admin off-boarding the same person between approve's `find_member` and its upsert lands every arm on `ELSE app_user.status` — the provisioning declines silently and approve stamps `approved` over it, losing the still-locked-out person from the queue permanently. **The structural cause, now stated in spec §6 so it is not rediscovered a fourth time: approve verified by *prediction* — it read the row, decided what would happen, and never read back what did.** Fixed by requiring the member to be `active` before the decision is stamped; nothing is committed until then, so a refusal abandons the provisioning with its transaction. Also fenced the matrix's fail-closed default, which nothing pinned. ⚠️ **The first version of that fence was itself too weak** — asserting only `409` passed while the matrix was wide open, because the new read-back check raised its own 409; the discriminator is that a matrix refusal grants no role. **That is the third test in this ticket to assert less than its docstring claimed.** 52 tests, eight mutants measured red and reverted sha256-identical. N6a is **not a gate** and does not move this row's colour; **merging it IS an owner gate** (§6 of this plan — `deploy.yml:202-203` replays migrations, so the merge arms an auth-behaviour deploy). N6b needs no code; one owner question (auto-promote on first sign-in?) is recorded in spec §6. **✅ N7 BUILT 2026-08-04** (`ws-24-n7-self-removal-guard`, spec §2 Step 5) — **off-boarding yourself.** `DELETE /admin/members/{email}` refused the caller; `PATCH /admin/members/{email} {"status": "suspended"}` reaches the identical `is_active=False` and had **no self-check at all** — it refused only because `assert_owner_survives` happens to fire in a one-owner org, so **adding the second owner §2 Step 2 exists to create opened it**, and `admin:members:manage` is the floor for undoing it. The Members page rendered the button, because it never learned who the viewer was. One shared guard now (`_common.assert_not_self_lockout`) called by **both** doors; the rule is **"any status that is not `active`"** rather than a list, so `invited` — equally a lockout, since `is_active` is `status == "active"` exactly — is covered by construction; comparison case-insensitive and empty-safe. The roster reads `access.email` and renders **This is you** where Suspend/Remove were, and the shipped-but-uncalled `DELETE` finally has a UI behind a confirmation that names the person. ⚠️ **Both guards answer 409**, so every refusal test discriminates on the detail text *and* on what was written; the dw4 pair seeds **two** owners so only the self-guard can be answering. 22 new cases + 8 vitest; six mutants measured red and reverted (PATCH guard deleted → 8 red incl. dw4; DELETE guard deleted → 2; `.lower()` dropped → the 4 casing cases; rule narrowed to an enumeration → the `invited` fence; browser guard ignoring self → vitest; Suspend rendered unconditionally → the page-wiring case). Test fake extracted to `tests/unit/_admin_fakes.py` and shared with `test_signin_requests.py` (52 passed, unchanged) rather than copied. **No migration and no new slug, so unlike N6a merging it is not an auth-behaviour deploy gate**; it is not a §1.1 gate item and does not move this row's colour. **✅ N8 BUILT + REPAIRED 2026-08-05** (`ws-24-n8-purge-member`, spec §2 Step 5) — **deleting a member permanently.** Remove was the only off-boarding and it is soft by design (status → `removed`, grants dropped, `app_user` kept because ~every user-scoped table keys people by address); that stays. `DELETE /admin/members/{email}/purge` is a **second, harder action beside it, never a `?hard=` flag** — a flag would put the irreversible path one typo from the reversible one. Decision: **purge the person, keep their work.** The identity, every grant, every credential, their private sessions and their `access_request` row go; what they authored and **the audit trail** stay, and nothing is anonymised (the address is the join key across ~50 tables, so scrubbing `owner_email` orphans their apps rather than hiding them). Fourth door on the one shared `assert_not_self_lockout`, plus `assert_owner_survives`; one transaction, audited before the commit, a count per table in the response. ⚠️ **Verification returned FAIL and the headline defect was a count that lied in the reassuring direction.** `task_accounts` cascades the SYNCED half of `gtd_items`, and the KEEP clause counted those rows anyway — 847 synced tasks came back as `kept: {"tasks": 847}` with all 847 destroyed; `gtd_projects` (same cascade) was on neither list. **The response did not miss a destruction, it reported it as a survival.** Fixed by splitting both tables on `account_id` the way `chat_session` is split on `visibility`. **Why nothing caught it is the durable lesson: every structural assertion compared a row-spec to itself, and the test fake models no foreign keys — so no cross-table claim was checked by anything.** `tests/unit/_schema_cascade.py` now derives the FK cascade graph from the numbered migrations and three fences use it (no KEEP clause inside the delete side's blast radius unless it is the exact complement of a DELETE clause; every cascade child with its own person column must be reported; the hand-maintained cascade map is compared to the schema). Two more gates were unfenced and are now pinned: **deleting the route's `require_permission` left 162 tests green** (the fallback floor is `admin:members:read`, which `manager` holds — hard-delete for every manager), and **`const confirmed = true;` in the confirmation left 32 pytest + 173 vitest green** (done-when 6 was tested by grepping for copy; the rule now lives in `confirmPurge.ts`). The cascade map was also understated in the dangerous direction — 15 of 20 email tables, `wa_media` one hop too high — now derived and pinned. Recorded not fixed: `acb_audit/log.py:49` swallows every exception, so "the audit entry survives a rollback" is true but "a completed purge always leaves an audit row" is not. 39 + 28 + 152 pytest, 178 vitest; nine mutants measured red and reverted. **No migration and no new slug**, so like N7 and unlike N6a, merging it is not an auth-behaviour deploy gate; it is not a §1.1 gate item and does not move this row's colour. +**Narrative (verbatim):** **Read this row before inviting anybody, and before assuming any other row's access work is safe to demonstrate with a second person.** Exactly one member is signed in (`vjvarada@fracktal.in`, §4). The question "is it safe to invite colleagues" had been re-derived in conversation repeatedly and recorded nowhere; the spec is the durable answer and `scripts/onboarding_preflight.py` is its executable half (**agent-safe to write, NOT to run against prod — `--mode local` is an agent's only mode**; it refuses the box-only checks rather than guessing, because `resolve_access` degrades to `is_active=False` on an unreachable DB too, so a local PASS on default-deny would be vacuous). **The blockers, each with a done-when in §1.1 — G4 is the one that closed: G1** the Caddy strip — `deploy/hostinger/caddy/Caddyfile:13-18` has **no** `header_up -X-User-Email` / `-X-User-Role`, and `acb_auth/deps.py:27-35` says in its own docstring that the reverse proxy IS the boundary, because nothing in that module can tell a forwarded identity header from a forged one. 🔴 OWNER-GATE to install (writing the repo file is agent-safe). **G2** `GATEWAY_INTERNAL_TOKEN` unprovisioned ⇒ service identity falls back to `LITELLM_MASTER_KEY` (`deps.py:108-117`), the key every agent's BYOK client holds; `GATEWAY_REFUSE_LLM_KEY_IDENTITY` (PR #346) makes that refusable and **ships OFF**, and is inert once the token is set. 🔴 OWNER-GATE (a credential, in two places — the Next BFF mirrors the same fallback at `lib/gateway.ts:58-61`, so flipping the flag with the token unset 401s every signed-in member). ⚠️ **G2 has a LOCKOUT mode, repaired in the preflight 2026-08-04.** Setting the token in `/opt/acb/app/.env` only — which is what "restart the gateway and the workbench" invites — leaves the BFF sending `sk-local-dev-change-me`, so every proxied browser call carries a bad Bearer with a real `X-User-Email` while an internal token *is* configured, and `deps.py:356-361` returns **NO_ACCESS for every signed-in member**. Check 1 read only `.env` and would have certified that state green; it now reads `workbench/control_plane/.env.local` too and FAILs naming the lockout when the two disagree. Do it by **redeploying** — `.github/workflows/deploy.yml:166-187` reconciles `.env.local` from `.env` in place on every deploy, so the only dangerous window is "provisioned by hand without a redeploy", which is exactly what a hand-run owner gate looks like. **G3** a restore path — **BO-23 is unbuilt**: there is no data-inclusive dump, no `pg_restore` inverse, no restore runbook and no pre-migration hook; `scripts/dump_schema.sh` is `--schema-only` (structure, zero rows). `scripts/backup_db.sh` and `restore_db.sh` are proposed on the **independent** PR #347 (`ws-0-bo23-backup-restore`) and are **not on this branch**. 🟢 agent-safe to write, 🔴 owner-gate to run or schedule. ⚠️ **Repaired 2026-08-04:** the preflight's check 4 used to assert an `acb-backup.timer` unit and a `MANIFEST.txt` that **BO-23's own done-when never specifies**, while testing no dump format, size or restore script — so a schema-only dump printed "Backups run, land, and are recent" over zero rows, and G3 could not have gone green even after BO-23 shipped exactly what it promised. It is now measured against `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-23 done-when 1-4 verbatim, plus a size floor on the newest dump; the timer is probed as a note, never asserted by name. **G4** the four owner-scoping holes (below) — **all four closed 2026-08-04, so this gate IS green. WS-24 is not**: G4 closes the holes that survive a *correct* identity, and G1/G2 are about the identity itself — an owner predicate applied to a forged `X-User-Email` is not a control.** **PR #348 IS in this branch's ancestry** — `permissions.py:95-100` carries the six `center.*` slugs, so the preflight's Centers check passes here. **✅ G4's N4 CLOSED 2026-08-04** (`ws-24-n4-people-scoping`, spec §4 N4's `owner-answered` DECISION block): **directory open, HR fields restricted.** `GET /tasks/people` still serves the org chart to any `feature:tasks` holder, but `skills`, `skills_source`, `resume_summary`, `years_experience` and capacity/current-load/available are projected to null/empty for a caller without `admin:members:read` (`routes/tasks/people.py` — `HR_FIELDS`, `_row_to_person(row, *, include_hr)` with **no default**, so a future route cannot inherit the permissive answer), and `?q=` drops its `unnest(skills)` clause for that caller so the search box cannot become an oracle for the field the strip exists to hide. All **four** write routes — `POST /people`, `PATCH /people/{person_id}`, `POST /people/{person_id}/resume`, and `capability.py`'s `POST /people/embed` — carry `require_people_write()` = `admin:members:manage` as a route dependency (`routes/tasks/core.py`). **No new permission slug** was minted, deliberately: a new slug is nobody's grant until an admin creates it, which would switch HR features off for the owner too; both permissions are existing `CAPABILITIES` entries and the owner's `*` matches both. Consequence recorded, not a defect: a `manager` (holds `admin:members:read`, not `:manage`) sees the HR half and cannot write it — consistent with the matrix. `fetch_people_for_clarify` is **unchanged** and still returns full rows: the projection is at the serialization layer, never in the SQL, so in-process agent delegation (`ai.py`, `capture_email.py`, `planning.py`) is untouched. `tests/unit/test_tasks_people_scoping.py`, 35 cases, three mutants verified red first. **✅ G4's N1–N3 CLOSED 2026-08-04** (`ws-24-n1n3-notes-scoping`, cut from `891903de`), all three reachable until then with the default `member` role because it holds `feature:notes` (`130:235`). **N1** — fifteen of the sixteen routes in the six named files (`recordings.py` upload/start/chunk/complete/audio, `qa.py`, `share.py`, `copilot.py` ×2, `live.py`'s `/stt/live-token`, `actions.py` ×3) now load through `core.load_owned_meeting` or bind `core.OWNED_MEETING_PREDICATE` and answer **404, never 403**. `_recording_path` — the loader `/chunk` and `/complete` share — carries the join, so neither can acquire the hole separately and the per-chunk path pays no extra round trip; `qa` loads the meeting **before** the transcript so the 409 "no transcript yet" stops being an oracle; the copilot **stream** checks before the `StreamingResponse` starts, because a 404 raised inside a started stream is a broken connection, not a refusal; `share.py` was read first and has no sharing mechanism to preserve (no grant, no token, no redemption — the send is a separate `/email/send` under the caller's own account), so the whole route is a read. **`live.py:256` stays machine-authed by recorded decision** — the caller is the bot worker with `MEETING_BOT_TOKEN` and no member identity, so an owner predicate has no owner, and both ways to invent one turn the bot token into a way to *assert* an identity; it discloses one boolean plus a settings-derived sentence, and the same answer for an id that does not exist. **N2** — `actions._load_action` joins `meeting` and binds the predicate, so both single-item routes inherit it; the test pins **both** harms separately (no `INSERT INTO gtd_items`, no `UPDATE action_item`, and the colleague's description never reaches a bound parameter), because a 404 alone would not have proved the exfiltration half. `approve-all` was *aligned* rather than left alone: already safe at the `_dispatch` seam, it answered **200 with an empty list** — "your meeting, nothing qualified" where the truth was "not your meeting" — and read the colleague's draft rows to get there. **N3** — the attach branch binds the predicate **into the `UPDATE`** (`UPDATE meeting AS m … WHERE m.id = … AND (lower(m.owner_email)=lower(:owner) OR m.owner_email IS NULL) RETURNING m.id`) rather than loading first: a load-then-write leaves a window, and this statement *is* the mutation. The acting principal is the **caller**, necessarily — it is the only identity the request carries, and checking the row against its own `owner_email` would compare the meeting to itself and pass every time; the asymmetry is preserved, not collapsed, and a test pins that the ingest side still reads `meeting_bot.requested_by`. Evidence: `tests/unit/test_notes_owner_scoping.py` 21 → **57 passed**, every non-owner case verified **red** against pre-fix behaviour *with the parameter renames already applied* (so each red is the security claim, not a `TypeError`), plus four mutants — drop the audio guard, drop the action-item predicate, drop the `bot_join` predicate, and compare against the wrong identity — each red on exactly its own cases with the tree byte-identical after revert. Notes suite **280 passed**, `test_org_access_enforcement.py` **31 passed**. ⚠️ **TWO findings recorded, neither fixed here.** (a) **N1's table was not exhaustive** — `routes/notes` has 24 modules and **nine** still carry zero owner predicates after this change (`summaries.py`'s `GET`/`PUT /meetings/{id}/note` + `GET .../actions`, `copilot_context.py`, `copilot_agenda.py`, `meeting_bot.py`'s four `/bot/*` routes, `live_transcript.py` incl. `POST /meetings/{id}/say` — which makes the notetaker *speak into somebody else's call* — `live_session.py`, `speaker_id.py`, `agenda_progress.py`, `events.py`). Minted as spec §4 **N5**, deliberately **outside G4**: G4's done-when is "each of §4's four tickets meets its own done-when" and all four do, and re-scoping an owner-facing gate is the owner's call. **Owner decision needed:** does N5 block colleague #1? (b) `/notes/meetings/{meeting_id}/live/wanted` is in **neither** `main.PUBLIC_ROUTES` **nor** `core.router`'s `exempt` list while both its siblings are in both — so `require_authenticated` and then the feature gate 401 the worker before `_check_bot_auth` runs, and the poll that decides whether to keep paying for streaming ASR is dead. Not fixed here because the fix *opens* a route, the opposite of this change's direction. `test_org_access_enforcement.py`'s own `GATED_ROUTERS` lists the path, which is how the drift stayed invisible — that registry is the test's opinion, not the router's. **Two findings that correct the received account of the roles, both in spec §3.0 — anything quoting `130` alone is wrong:** (a) role grants come from **two** migrations — `131_integration_memory_permissions.sql` additionally gives `member` `integrations:use:*` **and `memory:read_org`** (`131:70-78`), gives `manager`/`admin` `memory:write_org` too, and gives `guest` **nothing** (`131:80`); (b) **`data:org:read` grants nothing — it has zero consumers.** It is declared (`permissions.py:132`), granted to admin/manager/agent_service (`130:205, 221`) and listed in the legacy fallback (`access.py:148`), and **no route, query or predicate in the tree ever checks it**. So "manager has org-wide visibility" is a name, not a mechanism; what actually widens a manager is `admin:members:read` (the floor for the **whole** `/admin` package, `admin/_common.py:77-91`, and `is_admin: true` at `me.py:96`), plus `feature:approvals`/`observability`/`whatsapp` and `memory:write_org`. That is **D14**. **Three more measured cells worth carrying up here** (full matrix in spec §3): `feature:memory`, `feature:artifacts` and `feature:observability` are enforced **nowhere server-side** (`memory.py:45-48` gates on the internal Bearer then per-scope; `workspace.py:53` and `observability.py:46-51` gate on nothing beyond authentication) — they hide a nav pane and the per-object rule is the boundary, exactly as `lib/access.ts:126-129` says; **artifacts are shared for most agents**, because 4 of the 6 first-party `config.json`s declare `instancing: "shared"` ⇒ `instance_key()` = `''` ⇒ one workspace for everybody (`workspace.py:230-260` → `manifest.py:235-246`); and a **member can read/write every agent's memory compartment**, since `_authorize_agent` (`memory.py:103-109`) gates on `can_run_agent` and member holds `agents:run:*`. **Granting `feature:workflows` is a labelled consequence, not a defect** (spec §3.4): org-wide read is a recorded v1 decision (`crud.py:1-5`), the detail response returns `hook_token` (`crud.py:230`), and the hook route is unauthenticated by design (`core.py:29`, `hooks.py:3` — "the token IS the credential"), so the grant hands over a permanent copyable trigger for **every** workflow that survives off-boarding, and there is no rotate endpoint. **Not in this row:** building spec §4's new **N5** (the nine further `routes/notes` modules) until the owner says whether it blocks colleague #1, per-Center *data* scoping (WS-14/WS-15 — `140_center_features.sql:9-12` is explicit that Center features gate navigation and the landing pages, not data), and shared mailboxes (ownerless, §4). + +**Corrections applied 2026-08-09:** +- G3 (backups) is CLOSED — BO-23 nightly timer verified scheduled 2026-08-07, restore rehearsed 2026-08-05 (live=228 restored=228) +- the ports-open claim closed 2026-08-05 (UFW rules removed, verified from outside) +- 'backup_db.sh/restore_db.sh not on this branch' is stale — both plus rehearse_restore.sh are in scripts/ +- G2's rotation is unblocked (delivery recovered 2026-08-06/07 UTC, see deploy_delivery_path.md) +- D14's zero-consumer measurement for data:org:read is retired (WS-27d is its first consumer — re-verify the capability matrix before member #2). diff --git a/ai-company-brain/specs/competitive_hardening_2026-07.md b/ai-company-brain/specs/competitive_hardening_2026-07.md index 0fc3ff361..2b0084e0f 100644 --- a/ai-company-brain/specs/competitive_hardening_2026-07.md +++ b/ai-company-brain/specs/competitive_hardening_2026-07.md @@ -1,6 +1,6 @@ # Competitive Hardening — Learnings from Hermes Agent & OpenClaw -> **Status:** Planned (annealed into the backlog; no code yet) · **Created:** 2026-07-13 +> **Status:** Planned (annealed into the backlog **(2026-07-13; 'no code yet' is stale — BO-20a/20f built 2026-08-02, BO-20b slice 1 2026-08-03; per-item state lives in FOUNDATION_BUILDOUT_CHECKLIST.md)**) · **Created:** 2026-07-13 > **Source:** [`/COMPETITIVE_COMPARISON.md`](../../COMPETITIVE_COMPARISON.md) — an evidence-based three-way > comparison of CommandCenter against the two most-visible self-hosted agent platforms of 2026: > **Hermes Agent** (Nous Research — self-improving personal autonomous agent) and **OpenClaw** diff --git a/ai-company-brain/specs/crm_app.md b/ai-company-brain/specs/crm_app.md index 973731718..2f8408f8c 100644 --- a/ai-company-brain/specs/crm_app.md +++ b/ai-company-brain/specs/crm_app.md @@ -148,6 +148,9 @@ system of record, Zoho becomes an import source, then Zoho is retired.** - Multi-currency and exchange rates. INR only; a `currency` column exists with default `'INR'` so this is additive later. - Territories, sales hierarchies, per-team record visibility. Single org (D11); §8 D-CRM-3. + *[D11 was re-taken by D15 (2026-08-08): org-wide-read v1 stays the within-org design, and + cross-tenant isolation arrives via RLS at MT-1b — no hand-written org predicates; see + work_plan.md D15 and R5.]* - SLA/response-time engine, assignment rules, sequences/campaigns, marketing automation. - No-code custom-field or layout editors. Fields live in migrations; layouts in code. - Quoting/invoicing/taxes. Deal line items only (Phase C); billing stays out of scope. @@ -753,7 +756,10 @@ WS-2 (the standing "rotate Zoho token" P0 becomes "revoke", strictly better). - **D-CRM-3 — CRM data is org-visible to `feature:crm` holders in v1; `owner_email` is assignment, not ACL.** A CRM is a shared team surface (both reference products agree); D11 records one org, and the workflows app's org-wide-read v1 is the shipped precedent - (`routes/workflows/crud.py` records it). 404-not-403 owner scoping (R5) deliberately does + (`routes/workflows/crud.py` records it). *[D11 was re-taken by D15 (2026-08-08): + org-wide-read v1 stays the within-org design, and cross-tenant isolation arrives via RLS at + MT-1b — no hand-written org predicates; see work_plan.md D15 and R5.]* 404-not-403 owner + scoping (R5) deliberately does **not** apply — recorded departure per contract §7. Revisit with WS-14's `group:` grants when colleague #1 lands. - **D-CRM-4 — Engine seam:** `gateway/db.py::get_engine()`; `crm` consumes it, `tasks` is @@ -2072,3 +2078,21 @@ refused without a reason and accepted with one. box against the live DB. Name the files. The pr-check gates that bind: ruff `--select F821,F601,F602,F502,F7,B006` (blocking), xenon max-absolute F (blocking), frontend tsc + vitest (blocking). + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-26 — **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* +**State cell (as of the move):** ✅ **a + b + c BUILT + DEPLOYED** · ✅ **d read half BUILT + DEPLOYED** · ✅ **D2 = d-email BUILT 2026-08-07** (branch `ws-26d-email-timeline`, merged) · ✅ **D4 = d-write MERGED + DEPLOYED 2026-08-08 (PR #400, no migration; deploy 31217978773 log-verified)** · 🟢 **d-autolead dispatchable** · ✅ **D1 = f BUILT 2026-08-07 (branch `ws-26f-pipeline-truth`, NOT run against prod)** · ✅ **D3 = g BUILT 2026-08-07 (branch `ws-26g-reports`, no migration)** · 🟢 **DEMO CRITICAL PATH (owner-directed 2026-08-07, spec §9.0): ~~D1 f~~ (∥ D2 d-email) → ~~D3 g~~ → ~~D4 d-write~~ → D5 d-autolead** · 🟡 **h/i/e deferred past the demo; i spec-thin** +**Narrative (verbatim):** Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **migration 144 applied on prod and `/crm` live as of 2026-08-06**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Measured 2026-08-06: mig 145 is applied on prod and the BACKFILL HAS RUN — 737 orgs / 1,189 contacts / 1,516 leads / 551 deals / 1,909 notes, zero dirty rows, zero unmatched owners; the §7.1 pre-flip curl confirmed the tenant honors RFC-1123 `If-Modified-Since` (304). The PUSH direction has still never run: `CRM_ZOHO_SYNC` ships OFF, nothing has ever written the live Zoho tenant, and enabling the flag or hand-running a push cycle against prod stays OWNER-GATE §6.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Deployed:** migrations 144 and 145 are applied on prod as of 2026-08-06 and `/crm` is live, so live rendering, drag persistence and deep links are owner-verifiable now) · **d** integrations — **audited 2026-08-06 GO-NARROWED and the narrowed slice is BUILT** (branch `ws-26d-agent-crm`): `apps/agents/agent-crm/` (`crm-assistant`, MAF, four READ tools over the existing `/crm` routes carrying the caller's `X-User-Email`, read-only enforced at the transport by a GET-only method allowlist) registered in `_KNOWN_AGENTS` + `_AGENT_REGISTRY` + `agent_registry.json`, plus `"crm"` added to the WhatsApp `_KNOWN_SYSTEMS` allowlist **parse-only** (nothing writes `wa_contacts.entity_ref`, the `crm` context block stays `None`, both pinned by test). **The three held-back items are now DISPATCHABLE — their doc blockers (B3/B4/B5/B7) were closed 2026-08-06 in `crm_app.md` §9.1-§9.3, every anchor read off `origin/main` rather than recalled:** **WS-26d-email** (the timeline join is CALLER-scoped, never record-scoped — it reuses the email app's `_account_scope` predicate, copied into `routes/crm/` rather than imported per D-CRM-4, joins by thread not message, inbound `from_address` only, and needs a new address index at the next free migration number) · **WS-26d-autolead** (hook = `routes/email/scheduler_hooks.py::process_new_mail`, the one seam scheduler+manual+webhook all funnel through; the per-message rules loop was considered and REJECTED because a classifier outage there double-fires and history backfills never reach it; unknown-sender test mirrors `_maybe_block_cold`, colleague suppression via `is_own_mail`) · **WS-26d-write — BUILT 2026-08-08** (branch `ws-26d-write`, **no migration**: every route the four tools call already existed). `request_confirmation` awaited at the top of each tool before any mutating request is built, fail-closed, and the `non_interactive_default` keyword is asserted ABSENT from the whole module rather than asserted != "approve" — pinning the argument rather than the value means a mutant does not get to pick a spelling the fence has not heard of. `_ALLOWED_METHODS` **widened, never deleted**: `{GET, POST, PATCH}`, still checked inside `_request`, with `DELETE`/`PUT` and any `_delete`/`_put` helper still absent, so the check that used to enforce "read-only" now enforces "never destroys". Path fence extended past `ast.JoinedStr` to `.format`/`%`/`+` (the re-review's P2) and — the part that makes it maintainable — **tested against synthetic sources one per idiom**, so "the fence went blind" is a red test rather than a silent gap. Two supervisor rulings landed as built: `update_deal_status` resolves the stage BY NAME inside the tool against `GET /crm/statuses/deal` (no UUID on the LLM surface; an unknown name returns the real lane names), and a lost-type target requires a `lost_reason` resolved the same way against `GET /crm/lost-reasons` — pre-empting the 422 the "close this as lost" demo beat would otherwise hit — with the vocabulary **only ever read, never created**. `create_lead` takes **no `owner_email` argument at all** (the route derives it from the acting user), deleting an LLM-filled identity field from the surface entirely. ⚠️ **One recorded departure from done-when 1**: the invariant asserted is *no mutation before consent*, not *no HTTP before consent* — two tools must read to describe honestly what they are about to do, and every pre-card call being a GET is itself pinned; the two tools that owe nothing to a pre-read are still held to literally zero calls. `@_annotate_risk` is the shared annotation convention and is NOT enforcement; the Action Broker covers the Zoho push, not the native write — the two fail in OPPOSITE directions and are not interchangeable. 76 new hermetic cases + `test_crm_agent.py` 87 → 143; ten mutants run red and reverted. **Built, not deployed.** The push-queue question is CLOSED — **D-CRM-9 (owner, 2026-08-06): agent-originated writes queue for Zoho exactly like human ones** (🟡 remainder; the flip stays OWNER-GATE) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. **Pipeline blueprint added 2026-08-07 (spec §5.1) after the owner's first live board session found lanes out of order and imported stages at 0% probability — root cause: the importer appends unseen Zoho stages past the seeds at probability 0, and 144's seeds renamed Zoho's defaults so name-match missed them; the admin API that could fix it is headless.** New tickets: **f** pipeline truth + settings UI — **BUILT 2026-08-07** (branch `ws-26f-pipeline-truth`, **no migration**: 144 already carried `position`, `probability`, `type`, `closed_at` and `expected_close_date`). `routes/crm/stage_metadata.py` = `POST /crm/import/zoho/stages`, floor `admin:access:manage`, **dry-run by default** and `?apply=true` to write; **>1 pipeline STOPS before the DB is opened** (D-CRM-11); f4's `closed_at` proxy is a direct UPDATE that bypasses `mark_dirty_on_update`, asserted statically against the statement text AND the module's call graph because the shared fake writes only what a SET clause names. Two settings readers on the Zoho read client with `ZohoScopeError`/`ZohoApiVersionError` so **no-scope, no-data and no-such-endpoint are three different reported outcomes** — and no-scope is the *expected* first answer, since the tenant's refresh token was never minted with `ZohoCRM.settings.*`. D-CRM-10's clamp landed in `admin.py::_validate_status` reading the ROW rather than the payload (a PATCH naming only `type`, or only `probability`, contradicts the rule only in combination with what is stored). `?tab=settings` is the headless-API fix and needs no Zoho token at all. **Nothing has been run against the tenant — dry run included.** · **g** forecast & funnel reports off `crm_status_changes` — **BUILT 2026-08-07** (branch `ws-26g-reports`, **no migration**: 144's `crm_status_changes` already carried every column). `routes/crm/reports.py` = four read-only endpoints (`/crm/reports/{pipeline,funnel,win-loss,owners}`) on the shared gated router, plus `?tab=reports`. **`WEIGHTED_SQL` lifted into `core.py`** (pipeline re-exports it) so the forecast formula has ONE definition, and `core.status_wire` absorbed two duplicate status projections instead of gaining a third. **The cross-language parity mechanism is minted here, not inherited** — the `priority.ts ⟷ priority.py` "precedent" is two hand-kept mirrors joined by a comment with no shared fixture anywhere: `tests/fixtures/crm_weighted_parity.json` (new directory) is read by BOTH pytest (through the emitted SQL, whose expression `_crm_fakes._WEIGHTED_SUM_RE` parses out of the statement text) and vitest (through `board.ts::weightedDeal`/`weightedRows`). ⚠️ **The funnel is defined against what the log RECORDS, not what its name suggests**: `crm_status_changes` logs transitions only, so all 551 imported deals have zero rows and "entered" is a VISITED-SET union (`from_status`, `to_status`, and the deal's current stage) — a `to_status` count reports an empty funnel for the whole live board; dwell is grouped by **`from_status`** (the stage being LEFT, the opposite key from the naive reading); the log stores NAMES, so a renamed lane orphans its history and orphans are REPORTED in `unmatched`, never dropped; and NULL `closed_at` — every imported closed deal until f4's owner-gated backfill runs — falls outside the trailing window, with the count reported so a 0% win rate is explicable rather than mysterious. The lost-reason breakdown carries a NAMED unattributed bucket: the earlier "complete by construction" claim is FALSE, since the importer bypasses both gates and `lost_reason_id` is `ON DELETE SET NULL`. **No `GROUP BY` is emitted** — the ticket asked for the choice to be stated: per-key aggregates in `get_pipeline`'s shape, because the weighted expression binds the lane's own default as `:stage_probability` and a grouped statement would stop BEING the expression the fixture and the fake read. 47 hermetic cases + 20 vitest + the 14-row shared fixture on both sides, 5 mutants red. **Two findings:** the `entity_type='deal'` mutant initially SURVIVED (the funnel keys through deal ids, so a lead row is excluded twice over) — closed with a row stamped `lead` against a DEAL's id, which is realistic precisely because `entity_id` has **no FK**; and `_crm_fakes`' `lower(col) = :param` reader matched a NULL column, which SQL never does, making the unassigned-owner bucket count rows its own aggregate never summed · **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views (🟡 spec-thin, audit-narrow first). **Demo path (2026-08-07, spec §9.0): full chain + all gates intact, tickets re-sequenced not thinned; f gained f4 — imported won/lost deals have no `closed_at` (importer never stamps it), backfilled from Zoho `Closing_Date` as a labeled proxy via a direct UPDATE that MUST bypass dirty-marking or ~500 no-op pushes queue for the live tenant.** + +**Corrections applied 2026-08-09:** +- f and g are MERGED TO MAIN (#391, #397) — the 'on branch' wording was stale +- the body's 'Built, not deployed' sentence about d-write contradicted the row's own state cell — d-write is MERGED + DEPLOYED, log-verified via deploy 31217978773 (2026-08-08) +- every 'CRM_ZOHO_SYNC ships OFF / never run' sentence is struck — the sync loop was ENABLED BY THE OWNER 2026-08-06 (work_plan.md §6 WS-26 (a)) +- d-autolead is BUILT with PR #403 OPEN (merge + CRM_AUTO_LEAD flip are the owner's) +- d-email took two post-merge fixes not reflected here (0aa30dec timeline share, acc80d2d migration renumber) +- the autolead seam finding of b09093a8 (backfills also reach process_new_mail's seam) applies. diff --git a/ai-company-brain/specs/department_centers.md b/ai-company-brain/specs/department_centers.md index 06654829f..eca1976ea 100644 --- a/ai-company-brain/specs/department_centers.md +++ b/ai-company-brain/specs/department_centers.md @@ -1,6 +1,6 @@ # Department Centers — one platform, many projections -**Status:** Phase A shipped (UI scaffold + feature gating) — **but no Center is reachable by anyone on `main` today, owner included**: the `center.*` feature-vocabulary fix and its invariant tests are on the open branch `ws-13-centers-feature-vocabulary` (2026-08-03), **unmerged**. §2 records the defect and the registration checklist that prevents its recurrence. Phase B groups admin UI + seed shipped pending review (2026-08-01 — directory read view still open) · **Date:** 2026-08-03 · **Owner:** vjvarada +**Status:** Phase A shipped (UI scaffold + feature gating) — Centers reachable via the `center.*` feature vocabulary since 2026-08-03 (merged; nav gating decided by the catalog per #389). *(Header corrected 2026-08-09.)*. §2 records the defect and the registration checklist that prevents its recurrence. Phase B groups admin UI + seed shipped pending review (2026-08-01 — directory read view still open) · **Date:** 2026-08-03 · **Owner:** vjvarada **Verified against code:** 2026-08-03 (WS-14 doc remediation, on `ws-14-doc-remediation` off `bebbd924`; **repair round the same day off `264f881e`**). Scope of that pass: **§3 @@ -38,9 +38,17 @@ platform's apps, agents, memory, and workflows; it is not a second product that "feeds data back." This supersedes the earlier informal framing of per-department apps as separate -systems. It does not change the tenant rule: a *separate deployment* is reserved -for a separate organization (the multi-tenant path in -`multi_user_organization_research.md` §17), never for a department. +systems. It does not change the tenant rule — **but the tenant rule itself changed on +2026-08-08 (D15, `saas_multitenancy.md` §1)**, so state it in its current form: + +> **A Center is never a tenant.** A tenant is an `organization_id` row, isolated by +> Postgres RLS; a Center is an `org_group` *inside* one tenant. A separate **deployment** +> is now a *placement* (a priced tier), not the tenant boundary — and it was never +> available for a department either way. + +*(The superseded phrasing read "a separate deployment is reserved for a separate +organization, never for a department." The second half is unchanged and still binding; +the first half described D11, which D15 re-took.)* **Why not separate systems.** Every load-bearing capability shipped in July is cross-cutting and assumes one deployment: intersection authority @@ -107,8 +115,11 @@ once groups have a UI). Route guards: `lib/access.ts` maps `/centers/ → center.`. > **Seeding the catalog row is not enough — the slug must also be in -> `acb_auth.permissions.FEATURES`** (fix on branch `ws-13-centers-feature-vocabulary`, -> 2026-08-03, unmerged; until it lands, no Center is reachable by *anyone*). +> `acb_auth.permissions.FEATURES`** (fix built on `ws-13-centers-feature-vocabulary`, +> 2026-08-03 — ~~unmerged; until it lands, no Center is reachable by *anyone*~~ +> **MERGED; Centers reachable since 2026-08-03** *(corrected 2026-08-09 — and #389 has +> since made the catalog, not the code mirror, decide nav; retained as the defect +> record)*). > `/auth/me` returns `list(access.allowed_features())`, and that method iterates > the hardcoded Python tuple, never `feature_catalog`; the wildcard in > `feature:*` is only ever evaluated against those literals, so an owner @@ -529,7 +540,8 @@ the filter is the small part. - Floor control / steer / observer lane — multiplayer workstream (`docs/multiplayer/README.md` §8), tracked there. - Entity-graph RLS and consent records — org_access Phases 4–5. -- Multi-tenant / SaaS — research §17; untouched by Centers. +- Multi-tenant / SaaS — `saas_multitenancy.md` (WS-29; D15) — research §17 is + background only; untouched by Centers. ## 4. Open questions @@ -538,7 +550,10 @@ the filter is the small part. twelve sites across eight files were rewritten as "a second tenant deployment" — preserving each sentence's meaning, including the T2 security gate in `agent_platform_hardening_2026-07.md` §64. Decision - record: `work_plan.md` D9. + record: `work_plan.md` D9. *(2026-08-09: the "second tenant deployment" + phrasing those rewrites installed embodied D11 and has itself been + re-swept to organization/placement language after D15; this inventory + stays as history.)* 2. **R&D / Engineering Center?** Fracktal is a product company; a seventh Center (projects, test logs, design docs) is plausible. Deferred until a real workflow demands it. Adding one is **not** a one-file edit: work §2's @@ -554,3 +569,54 @@ the filter is the small part. 4. **Guest access to Centers** — org_access open Q4; a guest with `center.sales` only is a plausible contractor shape and needs a decision before external sharing. + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-13 — **Centers B — groups become real** (groups admin UI, seed six groups, People directory read view) + +**State cell (as of the move):** 🟡 + +**Narrative (verbatim):** Groups admin UI + six-group seed **built 2026-08-01, pending owner review** (`routes/admin/groups.py`, `/settings/groups`, seed migration; see `department_centers.md` Phase B update). People directory read view still open. The unlock for everything below. Single owner: Centers B (groups spec §6 step 5 and org_access Phase 2 are mirrors). ✅ **FIXED 2026-08-03 (`ws-13-centers-feature-vocabulary`): the feature-vocabulary half of this row is closed.** `acb_auth.permissions.FEATURES` now carries the six `center.*` slugs in migration-140 sort order, two invariant tests in `tests/unit/test_org_access_control.py` now fail loudly if one goes missing — `::test_every_center_has_a_feature_slug` (anchored on a literal `EXPECTED_CENTER_SLUGS`, because the first version *derived* the expectation from `CENTER_GROUP_SLUGS` and therefore went vacuous when that tuple was emptied) and `::test_centers_registry_matches_the_feature_vocabulary` (**parses** `lib/centers.ts` and pins it both ways to `FEATURES`, so the documented "add a Center" recipe can no longer reproduce this bug with a green suite). `department_centers.md` §2 now carries the five-place registration checklist. And the admin role editor groups its chips by `feature_catalog.category` with a real "Centers" heading (`settings/roles/page.tsx`, `Feature.category` union widened in `members/types.ts`). No migration was needed — 140 already widened the CHECK. **Separate, still open:** `workbench/control_plane/src/app/page.tsx:11-12` renders `NAV_SECTIONS` with **no** access filter, so the home grid still advertises every pane (Centers included) to every viewer while the sidebar correctly hides them — recorded in `workbench/AGENTS.md`. The finding as originally written, for the record: **Centers were unreachable by ANYONE, including the owner.** `/auth/me` returns `"features": list(access.allowed_features())` (`routes/admin/me.py:84`), and `allowed_features()` iterates the **hardcoded Python tuple** `acb_auth.permissions.FEATURES` (`:64-81` as the tuple then stood; `:73-101` after the fix) — sixteen slugs, **no `center.*` entry**. The frontend gates on exactly those slugs: `lib/access.ts:66` maps `/centers/` → `c.feature` (= `center.sales`…), `canUseFeature` is `access.features.includes(slug)` (`:118`), and `visibleSections` drops any pane whose feature is absent — **and drops the whole section when it empties** (`lib/nav.ts:229-233`). Net effect: the Centers section renders in neither nav, and typing `/centers/sales` hits `AccessGate`'s "You don't have access to this". Migration `140_center_features.sql` **does** seed six `feature_catalog` rows, but `allowed_features()` never reads that table — so migration 140's own comment ("owners and admins see all Centers via their `feature:*` baseline") is **false as written**: an owner holding `*` still gets an empty set, because the wildcard is only ever evaluated against the sixteen literals. The fix taken was the vocabulary one (`FEATURES` gains the Center slugs) plus the invariant test; making `allowed_features()` read `feature_catalog` was rejected — `permissions.py` is pure and does no I/O by design. + +**Corrections applied 2026-08-09:** +- People-directory item closed by WS-28b (2026-08-06); the nav-filter / "catalog-read rejected" claims were inverted by merged #389 (`747b65af`) — the catalog decides now. + +### WS-14 — **Centers C — scoping deepens** (tasks team slice, shared mailboxes, team-instanced agents, per-Center approvals) + +**State cell (as of the move):** 🟢 **unblocked 2026-08-03 (D12)** + +**Narrative (verbatim):** **The blocker is answered.** This row read "blocked on what makes a project a team's project" for weeks; **D12** answers it: **a project belongs to a team when an explicit grant row carries a `group:` subject** — *not* derived from assignees, *not* an owning column. Both alternatives and why they were rejected are recorded in `specs/tenancy_and_visibility.md` §4 (`DECISION (owner-answered 2026-08-03)`); §5's gap table is the app-by-app map, and §3.2 is binding on the mechanism — **extend the existing `email | group: | org` subject vocabulary, do not invent a second one.** ⚠️ **The primitive is narrower than previously claimed:** only **rooms** honour `group:` today (`routes/rooms.py::_valid_subject` `:100-111`, expanded at `gateway/rooms.py:181-199` — **corrected 2026-08-03 from the stale `:163-179`**, which is the `chat_session` SELECT, not the group join; the `SELECT g.slug` is at `:192`). `app_grants` does **not** — `routes/apps/grants.py::is_valid_subject` (`:68-85`) is `email | agent: | agents:*` and explicitly **rejects `org`** (`:77`); the "identical to grants.is_valid_subject" docstring at `rooms.py:103` is false and should be corrected by whichever ticket touches it first. **What it can now build, in order:** (1) the tasks team slice — a project grant table + a read path unioning "mine" with "granted to a group I'm in" (blast radius: 27 `user_id` predicates in `routes/tasks/items.py`); (2) the `dynamic_agents` sharing columns per D3 — re-verified 2026-08-03, `15_dynamic_agents.sql:7-20` has **no** owner/visibility/sharing column and a repo-wide grep finds none, so this migration is genuinely WS-14's, at the **next free number resolved at build time** (R1); (3) `group:` on the Custom-Apps grant subject, the cheapest conversion since `apps.visibility` already carries the three tiers. Shared mailboxes stay `email_app_master_plan.md`'s implementation, sequenced here (D5). **Not blocked on WS-8 Phase A** (D3 amendment) and **not** waiting on WS-13's UI — but note WS-13's new finding: the Center *surfaces* are currently unreachable, so scoping work will need that one-line feature-vocabulary fix to be demonstrable. ⚠️ **Re-audited 2026-08-03 → the row was NOT dispatchable as written; `department_centers.md` §3 Phase C was rewritten and this row now points at four lettered bullets, only two of which are work.** **C1 tasks team slice — 🟢 AGENT-SAFE**, and it is the whole of the near-term value: grant table decided (`tenancy_and_visibility.md` §4.1 = **D13**, `gtd_project_grant`, agent-proposed and overrulable, **no `role` column**), union read path, migration at the next free number resolved at build time, and a **404-not-403** assertion for the non-member (the shipped convention — `routes/memory.py:237-240`). ✅ **Repaired 2026-08-03** after review found C1's acceptance could go green with **no way to create a grant**: done-when 1 now names a caller-reachable creation path (`POST`/`DELETE /tasks/projects/{project_id}/grants` on the shipped `/tasks` router, `feature:tasks` + project ownership, 404-not-403 per `routes/apps/_common.py:459-475`, module wired into `routes/tasks/__init__.py`), done-when 2 requires the grant under test to be created **through that route** rather than by a fixture `INSERT`, and done-when 5 names the shared validator's home (`packages/acb_auth/acb_auth/permissions.py`) — it previously named no module and no shared home existed. **C2 shared mailboxes — 🟢 AGENT-SAFE for the doc action, build blocked, no owner in fact** (see §4; the bullet's old "NOT DISPATCHABLE" was a third gate token and was mapped onto the contract's two). **C3 team-instanced agents — 🟢 AGENT-SAFE but narrow:** the seven agents the old bullet named do not exist, and `t:`'s *writer already ships* (`acb_skills/manifest.py:242-246`), so the slice is the `dynamic_agents` columns (shape per `agent-kinds.md` §3, `:143-155`; **pre-provisioning — the columns are intentionally unread, per D3, and wiring a consumer is out of scope**) plus reconciling `agent-kinds.md` §6 against three shipped `config.json` files — **changing any existing agent's `instancing` is a silent memory/blob re-partition and is out of scope.** **C4 per-Center approvals — 🔴 OWNER-DECISION** (org_access Q2 open; `pending_actions` has no member/group/Center column). + +**Corrections applied 2026-08-09:** +- C1 must be re-audited against WS-27e's D-PM-6 one-store revision before dispatch; "only rooms honour `group:`" is stale — `pm_project_grants` shipped on the same vocabulary 2026-08-06. + +### WS-14a — **Tenancy TV-1 — the three `org_group` slug-only joins** *(minted 2026-08-03)* + +**State cell (as of the move):** 🟢 **AGENT-SAFE · 1 small PR** + +**Narrative (verbatim):** Owning spec: **`specs/tenancy_and_visibility.md` §2**, which passes all seven contract points and had **no board row** until now — §4 assigned it to a spec, and the dispatch loop selects from §2, so the corpus's most dispatch-ready ticket was undispatchable. `org_group` is joined on **slug alone** at three sites; slug is unique only *within* an org (`UNIQUE (organization_id, slug)`, `138_…sql:49`), and **two of the three sit inside the session-authority intersection**, where a too-wide group *widens* access. Nothing leaks today (D11: one org), but these are wrong within one org too, which is why they survive D11. **Anchors, re-verified 2026-08-03 — the previously-published ones were wrong at `520476ab` and are corrected in the spec:** (a) `apps/services/gateway/gateway/rooms.py:181-199`, the `SELECT g.slug` at `:192` *(was `:170-179` = `if row is None` + the participant fetch)*; (b) `:368-403`, `SESSION_VISIBLE_SQL` opening at `:368` with the slug join at `:377` *(was `:332-340` = the tail of `resolve_room_access`'s return)*; (c) `packages/acb_auth/acb_auth/access.py:330-336`, `_GROUP_MEMBER_SQL` — **correct, unchanged**. ⚠️ **The spec's own "verified red" requirement was unsatisfiable and was repaired in the same pass:** §7 named `tests/unit/test_session_authority.py` and `tests/unit/test_rooms.py` as the extension point, and both open with `pytest.mark.skipif(not _db_ready(), …)` (`:33-51` and `:33-52`), so a fixture added there **skips green** with no Postgres. §2 done-when 2 now attaches red-first to a genuinely hermetic string assertion over the three queries (which requires lifting anchor a's inline SQL to a module constant — that extraction is part of the ticket), and done-when 3 requires quoting a `-v`/`-rs` run showing the DB-backed fixture `passed`, never `skipped`. Numbered **14a** rather than a fresh WS-n because it is the `org_group`-join half of the same subject-vocabulary surface WS-14 generalises; the two are independent PRs and either may land first. + +**Corrections applied 2026-08-09:** +- Absorbed by WS-29 as MT-1i (2026-08-08); under D15 the three joins leak across tenants — the D11-era "wrong within one org, leaking in none" severity framing is superseded; the open two-org DB fixture criterion travels with MT-1i. + +### WS-15 — **Centers D — dashboards + Company Center** (Center dashboards, personal dashboard, weekly digest workflows, orchestrator org-memory fix per D4) + +**State cell (as of the move):** 🟡 WS-13 + +**Narrative (verbatim):** Digest workflows double as `workflows_app.md` G1 launch metric — one artifact, both scorecards. + +**Corrections applied 2026-08-09:** +- Unchanged. + +### WS-16 — **Centers E — AI budgets** (per-member caps at the LLM choke points; per-room degrade later) + +**State cell (as of the move):** 🟡 WS-6 + +**Narrative (verbatim):** Subjects per D2. + +**Corrections applied 2026-08-09:** +- Unchanged. diff --git a/ai-company-brain/specs/deploy_delivery_path.md b/ai-company-brain/specs/deploy_delivery_path.md index e5942efad..27e403ce3 100644 --- a/ai-company-brain/specs/deploy_delivery_path.md +++ b/ai-company-brain/specs/deploy_delivery_path.md @@ -1,9 +1,17 @@ # Deploy delivery path — getting merged code onto the box -**Status: 🔴 BROKEN — diagnosed and measured 2026-08-05, verified against code and -against the running deployment on 2026-08-05.** - -`main` is `d7d5c79b`; the box is `74082882` (#347). +**Status: 🟡 RECOVERED (re-measured 2026-08-09) — deploys landing again since +2026-08-06 (migrations 144/145 applied on prod that day); six green runs on +2026-08-07 UTC alone, the last being #400's log-verified deploy `31217978773` +(2026-08-08 IST, which is how `crm_app.md` dates it). Open: the tip run +(`b09093a8`, docs-only) failed health-verify ×3 rounds, 21:21→22:16 UTC +2026-08-07 — box one docs-only commit behind, cause unresolved. Everything below +this header is the 2026-08-05 diagnosis, kept as the record; re-measure before +quoting it.** + +`main` is `d7d5c79b`; the box is `74082882` (#347). *(2026-08-05 measurement — +stale: as of 2026-08-09 `main` is `b09093a8` and the box sits at `affe0647`; see +the Board record below.)* > **⚠️ CORRECTED 2026-08-05, same day.** This spec first claimed five PRs were > stranded, "including #355, the OAuth authorize fix, which is why mailbox @@ -19,9 +27,10 @@ against the running deployment on 2026-08-05.** > BFF OAuth route existing on disk dated 04:42. **Actually stranded: #357 and #358 — eight files, all documentation plus -`scripts/backup_db.sh`. Zero executable app code, zero migrations.** So the broken -delivery path has had **no production impact** to date. Its cost is entirely -forward-looking: the next app change to merge will not ship, and nothing will say so. +`scripts/backup_db.sh`. Zero executable app code, zero migrations.** So the ~~broken~~ +*(2026-08-05; recovered since — see header)* delivery path has had **no production +impact** to date. Its cost was entirely forward-looking — and the forward-looking cost +did not materialise: delivery recovered before any app change was stranded. This spec owns the *delivery path* only: how a commit on `main` becomes running code on the VPS. It does not own what the deploy script does once it runs @@ -84,6 +93,9 @@ curl https://api.github.com -> 200 in 0.029s ``` **Inbound is broken; outbound works.** Every option below follows from that one fact. +*(2026-08-05 measurement. By 2026-08-07 UTC inbound was reaching the box again — six +green runs — with one health-verify failure at tip; the fact this section rests on is +dated, and options A–C remain worth building for the next outage, not this one.)* ### 2.1 Why the existing retry logic cannot save this @@ -406,3 +418,20 @@ exits 1 saying so. That is the intended first-run state, not a fault. - `user_management_contract.md` — what #354/#355/#356 change, and why shipping them matters beyond "the board is out of date" - `colleague_onboarding.md` §2 — blocked at its final step until D4 lands + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-25 — **Deploy delivery path** — getting merged code onto the box *(minted 2026-08-05)* +**State cell (as of the move):** 🔴 **BROKEN — but no production impact to date** +**Narrative (verbatim):** **`main` is `d7d5c79b`; the box is `74082882` (#347).** ⚠️ **This row first claimed five PRs were stranded "including #355, the OAuth authorize fix" — CORRECTED the same day: #354, #355 and #356 are all LIVE.** The deploy does `git reset --hard origin/main`, so #347's successful 04:40 run carried everything merged before it, and those three merged by 01:18. **The error was reading box HEAD as if delivery were PR-by-PR — it is not: one successful deploy lands every commit merged up to that instant, so "the box is on PR n" says nothing about PR n+1, only about when the last success ran.** Verified per PR with `git log --grep` and by the BFF OAuth route on disk dated 04:42. **Actually stranded: #357 + #358 — eight files, all documentation plus `scripts/backup_db.sh`; zero executable app code, zero migrations, and nothing reads them at runtime**, so today's remediation is a `git fetch && git reset --hard origin/main` with **no deploy and no restart**, not the §6 stopgap. **The cost of this defect is therefore entirely forward-looking: the next app change to merge will not ship, and nothing will say so.** **Measured 2026-08-05:** deploy runs since 2026-08-04 alternate ~4-minute successes with **~54-minute failures** (the retry ladder running to exhaustion) — `ssh: connect to host ***: Connection timed out`, and `workbench=000000`, curl's no-response code, so the runner's **HTTPS** probe got nothing either. **The box was healthy the whole time:** across the 55-minute window 06:28–07:23 UTC `journalctl -u ssh` logged **four** lines — one operator key login and two immediately-closed scans — at load average 0.16, uptime 7 days, no reboot, while answering the operator's machine in 240 ms. No fail2ban (not installed), no iptables rules beyond UFW's own chains. **GitHub's packets do not arrive; the drop is upstream of the VPS and affects every port.** The asymmetry is the whole design input: the box reaches GitHub **outbound** fine (`git ls-remote` instant, `api.github.com` 200 in 29 ms). ⚠️ **`deploy.yml:546-559`'s existing retry logic cannot save this — it models the wrong failure.** It assumes the deploy *ran* and only the SSH teardown flaked, so it ignores the SSH exit code and verifies by health probe; sound for a teardown blip, useless when the session never establishes, and it converts a 4-minute no-op into a 54-minute one. ⚠️ **The structural obstacle, and why the obvious fix is a trap: `DEPLOY_SCRIPT` is a 435-line shell script defined as a workflow `env:` value (`deploy.yml:107-544`) and piped over SSH with `bash -s` — the box never holds a copy.** So a pull-based scheme must either duplicate 435 lines on the box, producing two deploy paths that silently drift (worse than the outage), or the script must first be extracted to a versioned file (**D1**) — which pays for itself anyway, since a script embedded in YAML cannot be shellchecked, hand-run during an incident, or diffed. **Second-order trap recorded in the spec §3:** the script's first act is `git fetch && git reset --hard origin/main`, so a box running it *from the checkout* has the file rewritten while bash is still reading it by byte offset; extraction must be two-stage — a small stable bootstrap that fetches, then `exec`s the fresh script. **Options in spec §4, recommendation A:** (A) a pull timer polling `git ls-remote`, depending only on the outbound path that is proven working, no daemon executing remote-authored jobs on the production host; (B) a self-hosted GitHub runner — far less bespoke code and keeps the Actions audit trail, but puts a job executor holding repo credentials on the prod box, acceptable only while the repo stays private and no forked PR can target it, a property that must then be *maintained*; (C) a Hostinger ticket, worth filing in parallel, worth waiting on for nothing. Under **both** A and B the health check can no longer prove external reachability — unavoidable today, since GitHub cannot reach the box to check. **D3 (failure is visible) is not optional:** this ran two days because the only signal was a red tick on a page nobody watches while the app stayed up and looked fine. **All four acceptance items are OWNER-GATE** (they change the deploy path and apply migrations forward-only). **§6 stopgap: the operator's own machine reaches the box, so the existing deploy can be driven by hand** — and the preconditions are already true as of 2026-08-05 09:29 (a verified restorable backup, `live=228 restored=228`, plus the nightly timer installed and enabled), which makes this the safest moment this deployment has had for it. **Blocks:** §6's `GATEWAY_INTERNAL_TOKEN` rotation, whose prescribed method *is* a redeploy — rotating before delivery works writes the new value into `.env` with no reconcile of `.env.local`, the exact lockout that item warns about; and `colleague_onboarding.md` §2's final step. + +**Corrections applied 2026-08-09:** +- the 🔴 BROKEN state is superseded by re-measurement 2026-08-09: deploys landing since 2026-08-06 (migs 144/145 applied on prod), six green runs on 2026-08-07 UTC alone, the last = #400's log-verified deploy 31217978773 (2026-08-08 IST; c1eba71f fixed the apply script git-resetting itself mid-read) +- the tip run (b09093a8, docs-only) failed health-verify ×3 rounds 21:21→22:16 UTC 2026-08-07 — box at affe0647, cause unresolved +- the row's main/box SHA pointers are stale +- D1 (extract DEPLOY_SCRIPT), SHA-in-/health and failure-visibility remain live +- under D15 delivery must become placement-parameterised (saas_multitenancy.md §5.1 condition 3). diff --git a/ai-company-brain/specs/email_app_master_plan.md b/ai-company-brain/specs/email_app_master_plan.md index b868b28c2..45e36b09d 100644 --- a/ai-company-brain/specs/email_app_master_plan.md +++ b/ai-company-brain/specs/email_app_master_plan.md @@ -1,7 +1,7 @@ # Email App — Master Plan (single source of truth) > **Product:** CommandCenter · **Feature:** Email AI Assistant App · **Created:** 2026-07-22 -> **Status:** 🟢 Live on the VPS, single Outlook account (`vjvarada@fracktal.in`), daily-driver. +> **Status:** 🟢 Live on the VPS, single Outlook account (`vjvarada@fracktal.in`), daily-driver. *(second mailbox Ishaanpilar@fracktal.in connected 2026-08-05 — re-verify §7's single-account premises at dispatch; noted 2026-08-09)* > **Last status change:** 2026-08-04 — **P0 connect-flow outage CLOSED** (§7 Tier 1 item 1, partial). > Nobody but the already-connected owner could add a mailbox from 2026-07-29 to 2026-08-04: > the Connect button navigated the browser straight at the gateway, which default-deny 401s. diff --git a/ai-company-brain/specs/groups_sessions_authority.md b/ai-company-brain/specs/groups_sessions_authority.md index deff1266c..d6d54a336 100644 --- a/ai-company-brain/specs/groups_sessions_authority.md +++ b/ai-company-brain/specs/groups_sessions_authority.md @@ -74,7 +74,8 @@ chat_session_participant(session_id, subject, role ∈ (owner|member|viewer), understand (`org_access_control.md` §2 obs. 1). - **Backfill:** every existing session gets one `owner` row from `chat_session.user_id` where it holds an email; the literal `'default'` - (pre-auth single-tenant rows) backfills as owned by the org owner. Every + (pre-auth single-tenant rows) backfills as owned by the org owner + *(historical backfill description — fine; new code follows D15/R5)*. Every session stays `private`, so deploying this changes nobody's access. - `viewer` is in the schema because the share flow needs read-only invitees (see the transcript rule in §4) — but a viewer still **caps the room's diff --git a/ai-company-brain/specs/live_meeting_copilot.md b/ai-company-brain/specs/live_meeting_copilot.md index f926f6f88..0596e7d7e 100644 --- a/ai-company-brain/specs/live_meeting_copilot.md +++ b/ai-company-brain/specs/live_meeting_copilot.md @@ -1,6 +1,6 @@ # Live Meeting Copilot — architecture plan -**Status:** Phases A-D BUILT (presence, console, passive copilot, business context, agenda + standing instructions). Phase E (speaking into the call) still planned. +**Status:** Phases A-D BUILT (presence, console, passive copilot, business context, agenda + standing instructions). Phase E (speaking into the call) still planned. *(status undated when found; last git-touch 2026-07-28 — treat as of that date; not re-verified since. Dated 2026-08-09.)* **Builds on:** `note_taker_app.md` §3.13 (meeting bot + live-transcript bus), the browser recorder + live captions, `acb_llm` tiers, the agent/skills/connector layer, and the notes auth/scoping. diff --git a/ai-company-brain/specs/mcp_plugin_integration.md b/ai-company-brain/specs/mcp_plugin_integration.md index 98afca44a..9733ff29e 100644 --- a/ai-company-brain/specs/mcp_plugin_integration.md +++ b/ai-company-brain/specs/mcp_plugin_integration.md @@ -1,7 +1,7 @@ # MCP & Plugin Integration — Design Brainstorm > **Status:** Phase A SHIPPED · Phases B–C not started (was: Brainstorm / Design proposal) -> **Date:** 2026-06-14 +> **Date:** 2026-06-14 · **Re-dated 2026-08-09:** Phase A shipped (D7) with the MAF-side injection gap ticketed as WS-8c (`agent_architecture.md` §12.2); Phases B/C remain research. > **Scope:** How Model Context Protocol (MCP) servers and Claude-style plugins > extend CommandCenter beyond the current REST API integration model. diff --git a/ai-company-brain/specs/meeting_bot_platform_plan.md b/ai-company-brain/specs/meeting_bot_platform_plan.md index 5d2c93fb7..f0694dcfe 100644 --- a/ai-company-brain/specs/meeting_bot_platform_plan.md +++ b/ai-company-brain/specs/meeting_bot_platform_plan.md @@ -155,7 +155,10 @@ streams; and there is **no calendar layer**, so every join is manual. Attendee has the best techniques *and* the most restrictive licence. Since CommandCenter is our internal tool (not a SaaS we resell), ELv2's use grant is -satisfiable — but the safe engineering posture is: **treat Attendee as a +satisfiable *[⚠️ 2026-08-09: this compliance argument rests on the retired D10 +premise — under WS-29 CommandCenter IS resold. Re-evaluate the ELv2 use-grant +(Attendee is ELv2, not OSS) before the first external tenant uses meeting-bot +features; flag carried in work_plan.md WS-19.]* — but the safe engineering posture is: **treat Attendee as a research paper.** The techniques below are architectural facts about Chrome and Meet, not Attendee's expression of them. @@ -501,6 +504,8 @@ are scale plumbing for people running thousands of concurrent bots. ## 4b. Multi-tenancy: one notetaker identity per organization The single-tenant design has a global bot identity and a single browser profile. +*(re-scope under WS-29: bot identity becomes per-org at MT-1+; this section +describes the current internal deployment)* The moment two organizations (or two users in different orgs) use the notetaker, that global is the thing that breaks — and the *email is the least of it*. @@ -552,7 +557,8 @@ Mirror the email app, which already solved per-account isolation: ### Sequencing note None of this is needed for one organization, and building it now would be -speculative. But two things should happen *before* a second org is onboarded, or +speculative. *(dated 2026-08-09: needed at the first external tenant — sequence +with WS-29)* But two things should happen *before* a second org is onboarded, or they become data-migration problems instead of design choices: the identity must be **a row keyed by org from the start** (not an env var), and the profile path must be **derived from that row** (not a constant). Both are cheap now and diff --git a/ai-company-brain/specs/memory_architecture.md b/ai-company-brain/specs/memory_architecture.md index ef61507b0..a3b0b6ec9 100644 --- a/ai-company-brain/specs/memory_architecture.md +++ b/ai-company-brain/specs/memory_architecture.md @@ -424,3 +424,17 @@ instance. (`acb_memory/session_cache.py`; `docs/multiplayer/memory-clearance.md` §7). Question 5 is a correctness issue, not a design preference — it was resolved during 3a′, as required. + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-9 — **Memory tiers 3b/3c/4** (budgeted file-tier header, provenance markers, correction UX, supersession) +**State cell (as of the move):** 🟡 Docs +**Narrative (verbatim):** 3a′ substrate shipped (migs 136–139). §6.7 correction UX is the highest-leverage UX item in the corpus. **Audited 2026-08-02 → NO-GO**: §9 gives acceptance for **3a′ only** (which is WS-10's, already shipped) — 3b/3c/4, the whole of WS-9, have none; §6.7 is experience prose with no endpoint, model or assertion; §6.5 ends "there are two honest paths… don't do both", an owner call presented as acceptance. Header still says `Draft / RFC · 2026-07-26` over a body stamped 2026-08-01 (R4). Paths are bare filenames whose line numbers have moved (`routes/memory.py` gate is now `_authorize_scope` :128-167; `_tool_injection.py:488-493` moved to `acb_skills/addendum.py` in WS-23 S3). **Verified substrate:** `MemoryClient` has search/add/get_all/delete and **no `update`**; the API has no PUT/PATCH; `/memory` already does list + semantic search + delete + clear-all (§5.5 understates it) but has **no edit, no provenance**, and hardcodes one of the **five** scope shapes (`` · `prefs:` · `room:` · `agent:` · `org:global`). No provenance/supersession fields exist anywhere. **NOT owner-gated** — the gate logic is testable against a fake with Mem0 disabled (41 tests, 0.58s); the real trap is inverted: **this box's `.env` already has Mem0 enabled, and `tests/unit/test_memory_integration.py` HANGS (measured exit 124); assume `test_memory_e2e.py` does too — name test files, never `tests/unit/`.** **D4 constraint:** `orchestrator/agents.py:520-534` reads only the user scope, so correcting an `org:global` fact would show fixed in the UI and change nothing on that path — PR-1 must restrict to ``/`prefs:`/`agent:` or say so. **Slice when specced (3c-0, AGENT-SAFE):** `PATCH /memory/{scope}/{memory_id}` reusing `_authorize_scope(write=True)` + the 404-not-403 membership probe at `memory.py:237-240`; `MemoryClient.update`; provenance in **Mem0's own metadata** (`corrected_by`/`corrected_at`/`supersedes`) — no new table; PATCH in the Next proxy; inline edit + compartment selector on `/memory`. **Scope creep to cut:** §6.1 instance-keying is WS-14's and the 3a′ remainder is WS-10's — this row should stop claiming both. + +**Corrections applied 2026-08-09:** +- ownership settled 2026-08-09 — the 3a′ remainder (subject: compartments) is WS-10's S1 +- this spec owns 3b/3c/4 only. diff --git a/ai-company-brain/specs/multi_agent_orchestration.md b/ai-company-brain/specs/multi_agent_orchestration.md index a2ec9517d..2a86b86c1 100644 --- a/ai-company-brain/specs/multi_agent_orchestration.md +++ b/ai-company-brain/specs/multi_agent_orchestration.md @@ -919,3 +919,15 @@ Related specs: [`agent_file_and_memory_framework.md`](agent_file_and_memory_fram | Phase 5.1 (Magentic/GroupChat as graph node types) | **WS-11** — [`workflows_app.md`](workflows_app.md) §8; sequences after Phase 4 | | Phase 5.2 (Shape C collaborative chat) | **WS-10** — shipped as multiplayer rooms (`docs/multiplayer/README.md`); floor-control residue is 🔒 OWNER-GATE | | **Phase 4 (framework uplift)** | **stays here — WS-12** | + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-12 — **Framework uplift** +**State cell (as of the move):** 🟡 Ph4 +**Narrative (verbatim):** **Audited NO-GO on all seven contract points; shrunk to Phase 4 only on 2026-08-03, not closed.** Ph0 shipped. **Ph1 struck** — 1.1 shipped as *progressive disclosure* (`93b93a08`, #191); 1.2 moot (`technical-project-planner` exists in neither `_AGENT_REGISTRY` nor `apps/agents/`); 1.3 delivered by **WS-23**. Ph2–3 superseded by the shipped Workflows app (D6). **Ph5 struck** — 5.2 shipped as multiplayer rooms *without* the orchestrations package, so it never depended on Phase 4; **5.1 is reassigned to WS-11**. **Ph4 is the genuinely undone part** — all four §5.5 shims re-verified in-tree 2026-08-03. **Drift correction: Phase 4 drags ONE SDK major, not two** — `uv.lock` and the repo `.venv` both carry `openai 2.38.0`, so the billed `openai 1.99 → 2.x` major already landed independently; only `github-copilot-sdk 0.1.32 → 1.0.2` remains. **0 PRs dispatchable today:** 4.0's target choice (minimal- vs full-bump) is **OWNER-GATE**; 4.1 (resolution proof in an isolated throwaway venv, evidence-only, AGENT-SAFE — it must never mutate `/.venv` or `uv.lock`) is what unblocks it. + +**Corrections applied 2026-08-09:** current as moved. diff --git a/ai-company-brain/specs/multi_user_organization_research.md b/ai-company-brain/specs/multi_user_organization_research.md index df070a734..838719f63 100644 --- a/ai-company-brain/specs/multi_user_organization_research.md +++ b/ai-company-brain/specs/multi_user_organization_research.md @@ -1,6 +1,6 @@ # Multi-User / Organization Architecture Research -> **Status:** Research & design proposal. **§4 (identity, membership, roles, permissions) is now IMPLEMENTED** — see [`org_access_control.md`](org_access_control.md), which also carries §8 credential scoping and part of §7 memory scoping. The rest (§5 modules, §9 entity-graph RLS, §16 data-heavy scoping, §17 SaaS) remains research, and the modules/session-sharing portion is owned by the **multiplayer agent collaboration** workstream — see that spec's §10 handoff contract before building from this document. +> **Status:** Research & design proposal. **§4 (identity, membership, roles, permissions) is now IMPLEMENTED** — see [`org_access_control.md`](org_access_control.md), which also carries §8 credential scoping and part of §7 memory scoping. The rest (§5 modules, §9 entity-graph RLS, §16 data-heavy scoping, §17 SaaS) remains research, and the modules/session-sharing portion is owned by the **multiplayer agent collaboration** workstream — see that spec's §10 handoff contract before building from this document. **2026-08-08/09:** §9 and §17 were un-superseded and are now **input to `saas_multitenancy.md` (D15, WS-29)** — the decision of record, which adopts §17.2's pooled-first recommendation and **rejects §17.3's header-based tenant resolution by name**. Read §17 as background; build only from saas_multitenancy.md. > **Created:** 2026-07-07 > **Scope:** How CommandCenter evolves from a single-tenant "internal company brain" into a multi-user organization account where personal data stays private, shared resources are selectively visible, and an administrator controls settings, modules, and agents. > **Companion docs:** [`project_plan.md`](../project_plan.md) · [`system_architecture.md`](../system_architecture.md) · [`learning-resources/05-auth-and-oauth.md`](../../learning-resources/05-auth-and-oauth.md) · [`specs/permissions_sandbox_b6.md`](permissions_sandbox_b6.md) @@ -876,7 +876,7 @@ The gateway should reject any query that does not include an `organization_id` f ### 16.5 Row-level security (RLS) as a safety net -Postgres RLS can enforce the above rules at the database layer. It is not a replacement for application filters (RLS can be bypassed by superusers and adds query-plan risk), but it is a valuable safety net. +Postgres RLS can enforce the above rules at the database layer. It is not a replacement for application filters (RLS can be bypassed by superusers and adds query-plan risk), but it is a valuable safety net. *[D15 supersedes this caution: the adopted design runs the app as a non-superuser non-owner role (acb_app) with FORCE ROW LEVEL SECURITY, fails closed on an unset app.tenant_id, and rewrites zero queries — see saas_multitenancy.md §1.3/§1.8.]* Example policy for `email_messages`: @@ -908,7 +908,7 @@ await db.execute( ) ``` -Use RLS in **audit mode** first (`POLICY ... FOR SELECT USING (true)`) to measure performance impact before enforcing. +Use RLS in **audit mode** first (`POLICY ... FOR SELECT USING (true)`) to measure performance impact before enforcing. *[D15 supersedes this caution: the adopted design runs the app as a non-superuser non-owner role (acb_app) with FORCE ROW LEVEL SECURITY, fails closed on an unset app.tenant_id, and rewrites zero queries — see saas_multitenancy.md §1.3/§1.8.]* ### 16.6 Sync architecture @@ -1121,6 +1121,8 @@ Each tenant gets their own Postgres database or even their own Kubernetes namesp 2. **Phase 2:** Offer schema-per-tenant as an enterprise option. 3. **Phase 3:** Database-per-tenant for dedicated enterprise customers. +> ⛔ **REJECTED MECHANISM (2026-08-08).** The header-based tenant resolution this section recommends — `X-Organization-Id` from the client — is **rejected by `saas_multitenancy.md` §7 item 2 and forbidden by `user_management_contract.md` R11**: the tenant comes from the authenticated session or a tenant-scoped API key, never from request input; the subdomain is a lookup, not an assertion. The code below is retained as research record only. **Do not implement it.** + ### 17.3 Tenant-aware application architecture #### Request routing diff --git a/ai-company-brain/specs/multiplayer_prior_art_qm_2026-08.md b/ai-company-brain/specs/multiplayer_prior_art_qm_2026-08.md index d2f334bf5..5619b05b6 100644 --- a/ai-company-brain/specs/multiplayer_prior_art_qm_2026-08.md +++ b/ai-company-brain/specs/multiplayer_prior_art_qm_2026-08.md @@ -25,7 +25,7 @@ where an outside team independently reproduced our most contested decision. **Read the age caveat first.** qm is three days old. "Shipped" here means *released*, not *battle-tested*. Its own `SECURITY.md` (`:26-33`) says QM "is **not** a hardened public or multi-tenant service boundary" and "assumes one organization of authenticated internal users" — -the same posture we are in. Nothing below should be read as "they solved it in production." +the same posture we are in *(posture dated: true until WS-29's first external tenant — D15)*. Nothing below should be read as "they solved it in production." --- diff --git a/ai-company-brain/specs/observability_e2.md b/ai-company-brain/specs/observability_e2.md index f34e571be..4c6f20033 100644 --- a/ai-company-brain/specs/observability_e2.md +++ b/ai-company-brain/specs/observability_e2.md @@ -1255,3 +1255,15 @@ reads "distributed/OTel tracing **dead** → **BO-5**") plus shape as `by_agent`. This is the *live* rollup only (45-day TTL, no per-call row): **WS-6d is unchanged and still open.** +8 tests (89 in the §7 verification set incl. `test_instance_wiring.py`). + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-6 — **Observability wiring + attribution** (BO-5 + decision D1) +**State cell (as of the move):** 🟡 partial +**Narrative (verbatim):** **Docs gate CLEARED** (PR #319 added the numbered §7 with nine lettered tickets WS-6a–i, per-item done-whens and gate labels). **Re-audited 2026-08-02 → GO-NARROWED to WS-6a+WS-6c only.** ✅ **BUILT 2026-08-02, pending review:** D1's attribution stamp exists as a substrate — `instance` joins `_RUN_CONTEXT_KEYS`/`bind_run_context`, resolved once in `run_agent_stream` via a **second additive bind** after `load_agent` (the early bind stays: it is what correlates a failure *during* load; moving it would trade 5 fields for 1), and `_emit_usage` carries the full (run, member, agent, instance) tuple with **zero call-site changes** — it arrives by inheritance via `activity._INHERIT`. Shared agents produce an **absent key, never `''`** (double-guarded + pinned). `refresh_run_presence()` patches `cc:activity:live:{run_id}` after the late bind, so `/observability/active` + `/roster` carry it; interim `by_instance` cost dimension added to the Redis rollup. **Nothing durable is written yet** — logs + Redis feed only. **🔴 WS-6b/6d/6e HELD, still NO-GO:** WS-6b's security amendment names *no workable mechanism* — `bind_run_context` has one call site (`executor.py`), contextvars do not cross the HTTP hop to `v1_compat`, and `agent_run` rows are written at the run *boundary* so a mid-run join finds nothing. **The only mechanism the code supports at request time is the presence key `cc:activity:live:{run_id}`**, which for the orchestrator path carries a server-established `user`; §7 must name it (or name another) before WS-6b dispatches. WS-6e has no token source (`build_run_trace_row` is pure over events+folded) so it sequences *after* WS-6b, not independently; WS-6d additionally waits on the retention/PII answer (Q3). **Two recorded asymmetries** — the `phase="start"` event predates the bind, and **a delegated sub-run inherits the caller's partition** while its blobs key to `''`, so WS-6d must not treat `instance` as a foreign key onto `agent_blob.instance`. **OWNER-GATE:** WS-6f/g/h/i (Langfuse keys, `--profile obs`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `LLM_USAGE_AUDIT`, the MAF telemetry kill switch) — all now listed in §6. + +**Corrections applied 2026-08-09:** current as moved. diff --git a/ai-company-brain/specs/org_access_control.md b/ai-company-brain/specs/org_access_control.md index 49e791015..9e64d7938 100644 --- a/ai-company-brain/specs/org_access_control.md +++ b/ai-company-brain/specs/org_access_control.md @@ -1,8 +1,8 @@ # Organization Access Control — implementation spec -> **Status:** 🟢 Phase 1 shipped. **Multi-tenant user management now hands off to the multiplayer agent collaboration workstream — see [§10](#10-handoff-multiplayer-agent-collaboration).** §10.4's requested spec exists: [`groups_sessions_authority.md`](groups_sessions_authority.md) decides the group primitive, `chat_session_participant`, and the authority rule (intersection). +> **Status:** 🟢 Phase 1 shipped. **Multi-tenant user management now hands off to the multiplayer agent collaboration workstream — see [§10](#10-handoff-multiplayer-agent-collaboration).** §10.4's requested spec exists: [`groups_sessions_authority.md`](groups_sessions_authority.md) decides the group primitive, `chat_session_participant`, and the authority rule (intersection). ⚠️ **Tenancy re-framed 2026-08-08 (D15, WS-29):** this spec's per-deployment framing ('the single-tenant deployment', 'the person who owns the deployment') predates row-tenancy. The intra-org model it documents is unchanged and correct; read `saas_multitenancy.md` for anything cross-tenant. MT-1a/H6 reopen the identity store (user_identity + org_membership); §10's 'complete, not being extended' claim is superseded for the tenancy axis only. > **Created:** 2026-07-29 · **Handed off:** 2026-07-29 · **Verified against code:** 2026-08-03 (WS-14 doc remediation — §8 Phase 2 row and §9 Q2 only; the rest keeps its earlier stamps). That pass found **`email_account_member` does not exist** (0 hits repo-wide in `*.sql` and `*.py`) and that §9 Q2 gates `department_centers.md` Phase C4 — both annotated in place. **Repair round the same day (off `264f881e`):** the Q2 annotation's claim that `pending_actions.actor` "is the proposing *agent* … not the human behind it, and no group is derivable from any existing column" was **false** and is corrected in §9 Q2 — two of `actor`'s six writers embed the requesting human's email. Q2 remains OWNER-DECISION on the corrected evidence. -> **Scope:** Turning the single-tenant deployment into a real multi-user organization: named members, roles, per-user feature/agent access, and one enforcement path the whole platform shares. +> **Scope:** Turning the single-tenant deployment *(wording predates D15 — one org per deployment is no longer the model)* into a real multi-user organization: named members, roles, per-user feature/agent access, and one enforcement path the whole platform shares. > **Parent research:** [`multi_user_organization_research.md`](multi_user_organization_research.md) — the *why* and the long-horizon (SaaS, memory scoping, entity-graph RLS) design. This document is the *what we are building now*, and it deliberately implements a subset. > **Companion:** [`permissions_sandbox_b6.md`](permissions_sandbox_b6.md) (tool-level risk gating — orthogonal: that answers "may this *tool call* proceed", this answers "may this *person* reach this feature at all"). @@ -27,7 +27,7 @@ So Phase 1 is deliberately narrow and load-bearing: - Per-user integration credential scoping — §8. - Entity-graph row visibility + Postgres RLS — §9, §16.5. - Modules/teams as a first-class container — §5. Phase 1 uses flat org membership; the `module` concept is what Phase 2 adds when "the sales team" needs to mean something. -- Everything SaaS (§17): subdomains, billing, tenant isolation of agent runtimes. +- Everything SaaS (§17): subdomains, billing, tenant isolation of agent runtimes. *(in scope since 2026-08-08 — WS-29, saas_multitenancy.md §0.9.3)* **Why this order.** Modules, memory scoping, and credential scoping are all *consumers* of a resolved principal. Building them first means building three private half-implementations of the same permission check. Phase 1 is that check. @@ -76,7 +76,7 @@ A role is a named bundle of permissions, scoped to an organization. Five are see | Role | Intent | Grants | |---|---|---| -| `owner` | The person who owns the deployment. | `*` | +| `owner` | The person who owns the deployment. *(under D15: owner is per-organization, not per-deployment)* | `*` | | `admin` | Runs the platform day to day. | `admin:*`, `feature:*`, `agents:*`, `apps:*`, `integrations:*`, `data:org:read` | | `manager` | Reads the member directory; cannot change platform config. | `feature:` chat, email, whatsapp, tasks, notes, memory, dashboard, observability, artifacts, approvals; `agents:run:*`; `apps:use:*`, `apps:create`; `data:org:read`; `admin:members:read` | | `member` | Default for a new employee. | `feature:` chat, email, tasks, notes, memory, artifacts, dashboard; `agents:run:*`; `apps:use:*` | @@ -206,9 +206,9 @@ feature_catalog(slug PK, label, description, nav_href, category, sort_order, is_ `feature_catalog` exists so the admin UI can render a checklist of real features without importing `nav.ts` into the gateway, and so a new pane is one seeded row rather than a code change in three places. -The migration seeds the default organization from `ALLOWED_EMAIL_DOMAIN`, seeds the five system roles with their permission sets, backfills every existing `app_user` into the org as `active`, and maps the legacy column: `role='executive'` → `admin`, `role='employee'` → `member`. Existing users keep working; nobody is locked out by deploying this. +The migration seeds the default organization from `ALLOWED_EMAIL_DOMAIN`, seeds the five system roles with their permission sets, backfills every existing `app_user` into the org as `active`, and maps the legacy column: `role='executive'` → `admin`, `role='employee'` → `member`. Existing users keep working; nobody is locked out by deploying this. *[⚠️ Trap under WS-29: this seeding is keyed slug='default' — org #2 gets no roles and no owner from it; see saas_multitenancy_implementation.md §7.1 and trap 5. Do not copy the pattern.]* -**Ownership bootstrap:** if no member holds `owner` after backfill, the first address in `EXECUTIVE_EMAILS` (else the oldest `app_user`) is promoted. A deployment with no owner is one where nobody can grant themselves access back. +**Ownership bootstrap:** if no member holds `owner` after backfill, the first address in `EXECUTIVE_EMAILS` (else the oldest `app_user`) is promoted. A deployment with no owner is one where nobody can grant themselves access back. *(per-org under D15: _HAS_OWNER_SQL carries no org filter — a lockout RLS does not fix; fixed under MT-1i)* --- @@ -426,7 +426,7 @@ Two things keep the list honest, both tested: a `PUBLIC_ROUTES` entry matching n ## 10. Handoff: multiplayer agent collaboration -**Multi-tenant user management is complete as Phase 1 and is not being extended on its own track.** Everything remaining — modules/teams, session sharing, memory and transcript scoping — is now owned by the multiplayer agent collaboration workstream, because those are the same primitives seen from two directions. Building them twice would produce two group models to reconcile later. +**Multi-tenant user management is complete as Phase 1 and is not being extended on its own track.** *(true for the intra-org model; the tenancy axis reopened 2026-08-08 as WS-29 MT-1a/H6)* Everything remaining — modules/teams, session sharing, memory and transcript scoping — is now owned by the multiplayer agent collaboration workstream, because those are the same primitives seen from two directions. Building them twice would produce two group models to reconcile later. This section is the integration contract. Read it before designing multiplayer; it says what you can rely on, what will collide, and what is deliberately absent. @@ -484,7 +484,7 @@ Not oversights — scoped out with reasons in the sections referenced. - Entity-graph row visibility + Postgres RLS (research §9, §16.5). - Transfer-on-removal: a removed member's private apps, sessions and workspaces persist unowned (§9 Q3). - Personal (BYO) integration credentials (research §8.3). -- Everything SaaS — subdomains, billing, per-tenant isolation (research §17). +- Everything SaaS — subdomains, billing, per-tenant isolation (research §17). *(in scope since 2026-08-08 — WS-29, saas_multitenancy.md §0.9.3)* - **True agent isolation.** Agents run in-process, so §8b's credential scoping is *authorization, not isolation*. That ceiling is **BO-7**, and it bounds what any access claim in this document can mean. - ~50 Next.js proxy routes still carry their own `EXECUTIVE_EMAILS` copy. Cosmetic — permissions resolve server-side from the email regardless — but `workbench/control_plane/src/lib/gateway.ts` is the single replacement when someone sweeps them. diff --git a/ai-company-brain/specs/people_center_app.md b/ai-company-brain/specs/people_center_app.md index e02165a3b..abc5ac89d 100644 --- a/ai-company-brain/specs/people_center_app.md +++ b/ai-company-brain/specs/people_center_app.md @@ -436,3 +436,15 @@ test failure. **The general lesson:** a migration that changes a table's *shape* has to be walked against every route that writes it, not only the ones that read it. The read routes were built after 148 and were correct by construction. The write routes predated it and were never revisited. + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-28 — **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* +**State cell (as of the move):** ✅ **a + b BUILT 2026-08-06 · b-write BUILT 2026-08-07** · 🟢 c–e dispatchable · 🔴 f owner-gate +**Narrative (verbatim):** Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE).. **a BUILT 2026-08-06** (mig `148_people_key_shape.sql` + `scripts/import_hr_people.py`; 22 cases, 11 mutants red, 1 equivalent): `UNIQUE(name)` dropped, partial unique on `lower(email)`, status CHECK. **P-1 did not name its own consequence** — the HR importer upserts `ON CONFLICT (name)` and would have failed outright, so a `source_key` (`:`) carries the upsert instead, backfilled BEFORE the constraint is dropped while `name` is still distinct. **Neither new constraint may block a deploy** (main was bitten twice this month): a duplicate address is quarantined into a new `email_conflict` column with a deterministic winner rather than failing `CREATE UNIQUE INDEX`, and the status CHECK is added `NOT VALID` then validated in a guarded block, so an unanticipated legacy value leaves a NOTICE instead of stopping the deploy. ⚠️ `schema.generated.sql` NOT refreshed — needs a live DB; regenerate on the first deploy that applies 148. **b BUILT 2026-08-06** (mig `149_people.sql`, `routes/people/`, `src/app/people/`; 32 hermetic + 28 vitest cases, 11 mutants red): `/people` directory, person page with all four panels, and the People Center's "Directory & org chart" sub-app flipped live — closing WS-13's outstanding read view. **Its own feature slug**, not `feature:tasks`: a manager who needs the org chart should not be handed the personal GTD task manager to get it. The gate is new but the HR **projection is imported** from `tasks.core` and a test asserts the function's *identity*, since two answers to "may this caller see skills" are two answers waiting to drift. Three filters (the `q` skills clause, `skill`, `has_capacity`) are dropped without `admin:members:read` so search cannot become an oracle for the hidden field — and the response carries `hr_visible` so the UI says "restricted" rather than leaving a blank strip to read as "nobody filled it in". Load is **computed from open assigned tasks** and carries `unestimated`, because a bar built from the estimate sum alone shows somebody holding thirty un-estimated tasks as completely free. The work panel is scoped by the **viewer's** grants and answers `available:false` without `feature:projects`. **Registration is FIVE places, not four** — the fifth is `test_org_access_enforcement.GATED_ROUTERS`, hand-maintained, where an absent router is unchecked rather than passing; also added the named `test_projects_is_registered_on_both_sides` that WS-27a never wrote. **b-write BUILT 2026-08-07** (`people/components/PersonEditor.tsx`, `people/lib/form.ts`, `people/lib/write.ts`; 26 hermetic + 23 vitest cases, 7 mutants red): closes the regression the 2026-08-06 scope narrowing opened — deleting the tasks app's People view took `PersonEditor` with it, and with it the only UI for creating a person, editing skills and uploading a résumé. The GET-only `/api/people` proxy is **unchanged**: the writes go to `/api/tasks/people`, where they have always lived. Controls are absent rather than disabled, driven by a new **`can_manage`** flag on the reads — hide-rather-than-disable is impossible unless the read tells the UI, and the alternative is drawing the button and letting the click find a 403. **Restoring it turned up three ways migration 148 had already broken the write routes**, each a 500 in front of an admin rather than a test failure: the status vocabulary moved (49's `inactive`/`on_leave` vs 148's CHECK) and is now ONE tuple shared by filter, facets, select and validation; `create_person` still refused a duplicate NAME, preserving precisely the behaviour 148 dropped `UNIQUE(name)` to remove; and nothing checked the address 148 made unique, so a duplicate was an `IntegrityError` instead of a 409 naming the other row. **The lesson recorded in spec §10:** a migration that changes a table's shape has to be walked against every route that WRITES it — the read routes were built after 148 and were correct by construction, the write routes predated it and were never revisited + +**Corrections applied 2026-08-09:** schema.generated.sql regeneration is DUE — stale since ~migration 113, and migration 148 reached prod ~2026-08-07 after the #384 cast fix. diff --git a/ai-company-brain/specs/permissions_sandbox_b6.md b/ai-company-brain/specs/permissions_sandbox_b6.md index caa564c73..35972fe57 100644 --- a/ai-company-brain/specs/permissions_sandbox_b6.md +++ b/ai-company-brain/specs/permissions_sandbox_b6.md @@ -8,6 +8,10 @@ > deprioritised sub-project** under the internal-tool threat model (owner decision > 2026-08-03) · **P5-d not started.** The two dispatchable slices are **WS-3a** > (§P5-a.2) and **WS-3b** (§P5-b.2). Board row: `work_plan.md` §2 WS-3. +> *[Update 2026-08-09 — D16: the parking survives, the trigger changed. P5-c/T2 +> is now a precondition of the pooled cutover (`saas_multitenancy.md` §5.1, +> MT-0c-2); the internal-tool premise expires with the first external tenant. +> Acceptance remains unwritten by design until the owner un-parks.]* > **Module:** B6 (core_module_map.md). > > **Isolation ladder (R2).** This doc's Phase-5 build order is lettered **P5-a/b/c/d**. @@ -507,6 +511,10 @@ An agent asked to "finish P5-b" builds **P5-b.2 only** and refuses this by name. ### P5-c — Generalize the container to a live, streaming run sandbox — 🔲 **PARKED SUB-PROJECT** (owner decision 2026-08-03) · **OWNER-GATE to un-park** +> ⚠️ **Read the D16 update at the end of this box first (2026-08-09):** the parking +> survives but the premise below is dated and the un-park trigger changed — it is the +> **pooled cutover**, not "a second organisation". +> > **Why this is parked, not cancelled.** Command Center is an **internal Fracktal > tool**. The team uses it; there are no external tenants and no customer-authored > agents. So the isolation ladder has to hold up to **trusted colleagues, not @@ -530,6 +538,11 @@ An agent asked to "finish P5-b" builds **P5-b.2 only** and refuses this by name. > Un-parking it is an **owner decision**. An agent asked to "finish the isolation > ladder" builds WS-3a and WS-3b and refuses P5-c by name. > +> *[Update 2026-08-09 — D16: the parking survives, the trigger changed. P5-c/T2 +> is now a precondition of the pooled cutover (`saas_multitenancy.md` §5.1, +> MT-0c-2); the internal-tool premise expires with the first external tenant. +> Acceptance remains unwritten by design until the owner un-parks.]* +> > **Do not confuse P5-c with what shipped.** `copilot_sandbox.py` containerizes > the **`copilot` CLI binary** and is wired at two call sites behind a scope > setting that ships empty. The host still owns orchestration, tool execution and @@ -565,6 +578,7 @@ objection). Layer intent-level authorization over allow-everything. > not "partially" default-deny an in-process run to make progress — that is > precisely the false assurance the paragraph above warns about, and against > colleagues rather than attackers it buys nothing while breaking real work. +> *[See the D16 update note at §P5-c — trigger re-scoped 2026-08-09.]* > > The one piece of P5-d that is separable is the **near-term handler's mode**, > which already exists: prod runs `AGENT_PERMISSION_MODE` in `audit` and moving @@ -587,6 +601,7 @@ buying* when the trust boundary moves — a second org, or authorship outside Fracktal. Against colleagues, **B is the right resting grade**, and the module map's "container isolation for normal runs" item should be read as *"open, and deliberately parked"* rather than *"open, in progress"*. +*[See the D16 update note at §P5-c — trigger re-scoped 2026-08-09.]* ## Status (Phase 5) - 2026-07-04 — Design from the B6 Phase-5 recon (mutation-container primitive + @@ -632,7 +647,8 @@ deliberately parked"* rather than *"open, in progress"*. 5. **Wrote acceptance for exactly two slices**, WS-3a (§P5-a.2) and WS-3b (§P5-b.2). Both AGENT-SAFE, both dispatchable, neither needing a container. 6. **Parked P5-c and P5-d** under the owner's 2026-08-03 internal-tool threat - model, with the un-parking condition stated. Neither gets acceptance. + model, with the un-parking condition stated. Neither gets acceptance. *[See + the D16 update note at §P5-c — trigger re-scoped 2026-08-09.]* 7. **Struck WS-3's claim on `tool_scope` deny** — that is built and owner-gated under **WS-23** (`_tool_injection.py:101-117` + `:214-224`, spec `skills_scope_out.md` §4), not this row. @@ -642,3 +658,19 @@ deliberately parked"* rather than *"open, in progress"*. `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO‑7 correction (its stale `loader.py:1247` / `:1095` anchors, and its CH‑1 note recommending a flag set that has already been adopted). + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-3 — **Isolation ladder** (BO-7 / HH-6 — T0/T1/T2 per `agent_platform_hardening_2026-07.md` §1.2) + +**State cell (as of the move):** 🟢 **WS-3a** (record + refuse, §P5-a.2) · 🟢 **WS-3b** (rootfs + network posture, §P5-b.2) + +**Narrative (verbatim):** P5-a (per-run credential scoping, 2026-07-04) + P5-b.1 (cap/resource ceilings, 2026-07-27) shipped. **T2 / P5-c PARKED** under the internal-tool threat model (owner decision 2026-08-03, D10) — the ladder must hold against trusted colleagues, not hostile users; **un-parking is OWNER-GATE**, and no acceptance should be written for P5-c until it happens. P5-d is blocked behind it. **Two claims struck from the old title:** `tool_scope` deny belongs to **WS-23** (shipped there), and "T2 for non-first-party agents" named a distinction the code does not carry — no `first_party` field exists on any manifest, config or column; the phrase occurs only in comments and one test helper. **OWNER-GATE:** the `AGENT_PERMISSION_MODE` enforcement flip · P5-b.3's scoped gateway key (unbuilt *and* undesigned) · the new `ISOLATION_TIER_ENFORCE` flip WS-3a introduces. + +**Corrections applied 2026-08-09:** +- T2/P5-c parking re-framed by D16 (2026-08-08): the un-park trigger is now "precondition of the §5.1 pooled cutover (customer 8–12)" per `saas_multitenancy.md` — not "a second org on this platform, or agent authorship from outside Fracktal". Acceptance still must not be written until the owner un-parks. +- MT-0b's migration 157 adds `organization.first_party`, retiring the row's "no `first_party` field exists" note. diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index 07e3f4482..2cfbbe7ea 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -1479,3 +1479,18 @@ tests failed for a reason with nothing to do with the code under test. The probe matched first and the audience branch keys off `assignee AS who`, which only its own query has. A fake that dispatches on substrings needs its fingerprints to be *specific*, not merely present. + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-27 — **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* +**State cell (as of the move):** ✅ **a + b + d + e + i BUILT 2026-08-06 · j + k + l + m + n BUILT 2026-08-07** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced +**Narrative (verbatim):** Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email|group:|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g — and n is now BUILT (2026-08-07), so that dependency is SATISFIED**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end. g itself stays 🔴 OWNER-GATE for its own reasons (§6); this changes the prerequisite, not the gate · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store).. **j BUILT 2026-08-07** (mig `152_projects_notifications.sql`, `routes/projects/notifications.py`, the header bell + mention picker; 39 hermetic + 27 vitest cases, 10 mutants red): closes §11.2's second gap — *"assignment is silent"*. **Three rules decide who hears**, each the whole reason for a rule: never the actor (a bell that pings you about your own click gets muted, and a muted bell notifies nobody about anything); never an agent (they are handed work by the WS-27f dispatch sink, so a row addressed to one sits unread forever inflating a badge nobody can clear — enforced in Python AND by a CHECK); and **never somebody who cannot open it**, which is the security property: the notification carries the task's TITLE, so delivering one outside the grant closure leaks it and lands them on a 404. That third rule needed `resolve_visibility_for`, which answers for a THIRD PARTY — `resolve_visibility` reads a `UserContext` and the recipient of a mention has no request in flight — by reading the tables `/auth/me` reads and handing them to the **real** `build_access`, so wildcards and allow/deny overrides resolve identically on both paths. Notifications are written **inside the transaction**, not emitted on the bus: `emit` swallows failures by construction so a broken workflow can never fail a task edit, which is right for agent dispatch and wrong here. A mention is an **address, not a name**, because 148 dropped `UNIQUE(name)` — `@Priya` has no answer. **Two bugs found on the way in, both shipping at the time:** every project-task file upload was answering **422** (`ACTIVITY_TYPES` never learned 150's `attachment`; all 25 attachment tests passed because they monkeypatch `record_activity` — the seam under test was mocked out), fixed with two tests that READ the migrations; and `/projects?task=` did nothing, though the People Center has linked there since WS-28b. **WS-27b's UI BUILT 2026-08-07** (`components/ImportClickUp.tsx`, `lib/importPlan.ts`; 18 vitest cases, 3 mutants red): the importer shipped with WS-27b and was **unreachable from the product** — the empty state named "import a ClickUp workspace" and no control anywhere did it, so a new install stayed empty and the only route to real data was curl. Three steps, only the last of which writes: Preview (`/plan`, reads the tenant) → Dry run (`dry_run:true`, exercises the flattening) → Import. **The mapping stays the owner's act (D-PM-10)** and the UI is built so it stays one: the suggestion is pre-filled and shown beside its confidence IN WORDS ("a guess — check it", not `0.45`, because a bare number invites acceptance without looking), and a CONFIRMED mapping always beats a fresh suggestion so a re-run never silently re-maps a Space somebody already ruled on. Unmapped Spaces are a notice rather than a blocker, matching the importer. ⚠️ The gate is unchanged and is now exactly one click: building this was agent-safe, pressing Import is the owner's act, and no agent has run either endpoint against production. **The Tasks-app mirror path BUILT 2026-08-07** (`routes/projects/import_tasks.py`, `POST /import/from-tasks`; 43 hermetic cases, 7 mutants red) — owner-directed: *"just show up all the data that is there in the Tasks app inside the Projects app"*. A SECOND importer rather than a flag: the ClickUp one needs a live token, spends LLM budget and demands a Center mapping BEFORE anything is written, which is backwards for "show me my work today". This reads `gtd_projects`/`gtd_items` — the mirror already on the box — so no API call, no token, no model spend, and it works when the connector is stale. One named department, with the real ClickUp shape beneath it — **Space → Folder → List** rebuilt as projects, each carrying its own `clickup_id`/`clickup_kind`, so promoting a Space node is how the department split happens later. **Verified against a real Postgres** (full migration set + seeded mirror), which found THREE defects the hermetic suite could not: `gtd_projects.space_id` is LOCAL-only so the Space was always NULL; `pm_projects` has no `clickup_snapshot` column, so the first real click would have 500'd (this shipped in #393 and was fixed before anybody pressed it); and the preview under-counted 4 vs 7 because container nodes were only tallied on the write path. The root IS org-granted (same act as `create_node`, narrower than bulk-granting a tenant). Pinned: nothing outside `pm_*` is written; only `source <> 'LOCAL'` rows are read (a personal capture must not be published to a shared board); the provider's own status names are kept; an orphaned task is COUNTED, not dropped. **Mutation found a real gap**: deleting `dry_run` from the write guards left every test green, because on a FIRST dry run `root_id is None` blocks the write anyway — on a SECOND one the department exists and `dry_run` is the only protection. That is the realistic case (preview → import → preview again) and it now has its own test. **k BUILT 2026-08-07** (`routes/projects/filters.py`, `lib/grouping.ts`, `components/FilterBar.tsx`; 34 hermetic + 24 vitest cases, 13 mutants red, and 23 checks against a REAL Postgres) — closes §11.2's third gap, the one whose name was the sentence *"my open bugs in Ops, grouped by assignee"*. **ONE filter builder serves both the list endpoint and saved views**, because a saved view is nothing but a stored set of these filters and two implementations would drift — a *saved* view showing a different set than the same filters typed by hand is the one thing it may not do, so a test compares the two outputs directly. **Every filter is a WHERE clause**: paging happens in SQL, so a filter applied in Python after `LIMIT` returns short pages, and *"page 2 is empty but there are 40 more"* is a bug people work around for months instead of reporting. **`overdue` means past due AND still open** — a finished task with a past due date is done, and permanent red is how a board teaches people to ignore red. **An unknown category is a 422 naming the five real ones**, not an empty board a client reads as "this project is empty". Unknown config **keys** are DROPPED while a bad **value** falls back, because those are different failures: a view is a preference written by an older client, so refusing one over an unrecognised key would make every deploy a migration of everybody's saved views, whereas rendering still has to produce something. On the board, **a task with two assignees appears in BOTH columns** (it is both people's work; picking one hides it from the other, so the header counts tasks not group sizes), **empty status lanes are kept** while every other grouping drops empties (a missing "In progress" column reads as "no such state", not "nothing in progress"), and **dragging is offered only when the columns are statuses** — a drop writes the field the columns represent, and status is the one that is a plain PATCH, so a card that can be dragged into a column which cannot accept it and snaps back is worse than an honestly static column. `toConfig` is deliberately NOT `toQuery`: a query string carries only text so `toQuery` writes `"true"`, and `fromConfig` refuses a string where a toggle belongs, so a view built from query shape would come back with every toggle silently cleared. The project's **order-bearing board is withheld from the chips** — `tree.py` seeds one `board` view per project and it owns every `pm_view_task_positions` row, so offering its ✕ would offer to delete every hand-arranged position; saved views sit at position 300 above the seeded pair and `orderBearingView` is one function used by both the drag handler and the delete guard. **A FIFTH live bug, found the same way as the previous four:** `due_before` was `CAST(:due_before AS timestamptz)` with the raw query-string value — asyncpg infers the parameter's type FROM that cast and then refuses to encode a `str`, so the query never reached Postgres and **`?due_before=…` answered 500** while the hermetic fake, which agrees with whatever SQL it is handed, stayed green. `parse_when` parses on this side and binds a real `datetime`; garbage is a 422 that says what was expected; a naive value is read as UTC rather than inheriting the connection's TimeZone. Two tests — one on the bound value's TYPE, one refusing any `CAST(:param AS timestamp…)` anywhere in the builder — so the next `after=` filter written the obvious way fails in CI instead of in front of a member. **l BUILT 2026-08-07** (mig `155_projects_custom_fields.sql`, `routes/projects/custom_fields.py`, `lib/customFields.ts` + the panel block and the Fields dialog; 47 hermetic + 36 vitest cases, 23 mutants red, 35 checks against a REAL Postgres) — ClickUp's signature feature, and the shape §5's non-goals already recorded as the additive path: **definitions in a table, values denormalised onto the task as JSONB keyed by `field_key`**. NOT a row per (task, field): that is the textbook EAV answer and costs a join per field on every board paint — five fields across two hundred imported tasks is a thousand rows to gather and re-pivot, per render — whereas the JSONB column arrives with the task for free. **The cost is stated rather than discovered**: a value is not referentially tied to its definition, so the DATABASE cannot stop a key no definition owns from being written, and that guarantee moves into Python. Hence the validation IS the feature: an unknown key is a **422 not a silent drop** (a typo that no-ops looks exactly like a save); a patch **MERGES** (a client that knows three of five fields must not wipe the other two — and an older client, or an automation written before a field existed, is precisely that client); an explicit **null CLEARS the key** rather than storing a null, because it is the only way to express "unset this" and a stored null makes "never filled in" and "deliberately emptied" one value in every filter; and **`true` is not the number 1** — `isinstance(True, int)` is True in Python, so the coercers are one-per-type in a dispatch dict specifically so the boolean check can never drift below the number one. **The deliberate departure from Paca:** its research notes record "deleting a definition does not clean task data" as an accepted cost; it is NOT accepted here, because a key left in the JSONB is invisible — no definition means no column, no form row, no filter — until somebody recreates the name and every old value resurfaces carrying the new meaning. The cleared count is reported (R7/R8). Two things a definition may not change once values exist, both because the stored values would stop meaning what they say: **`field_key` is never editable** (it is the identity every value is filed under) and **`field_type` is a 409 naming the count** (text→select cannot re-interpret what is already written); dropping a select option some task holds is refused the same way, adding one is free, and the UI shows the derived key while the name is still being typed since that is the last moment anybody can change it. **Custom fields are REVERTIBLE, which is what makes them first-class:** `patch_task` folds a custom edit into the SAME `field_change` activity under `custom.` rather than inventing an activity type — `record_activity` refuses a type the CHECK does not list, the trap that made every attachment upload answer 422 — and revert restores by **merging onto what the task holds NOW**, never writing back the whole object, since another field may have been edited since and replacing the blob would silently undo that too. **A bug the ticket's own tests caught before it shipped:** `changedValues` compared a form's boolean against a `null` baseline, so a task with an unanswered checkbox sent `open:false` on EVERY save and posted a timeline entry for an edit nobody made — a checkbox has no "unset" state to render, so `false` is its baseline. **And a fence that was quietly a subset check:** `test_projects_routes` asserted a LIST of mounted paths, which catches the module somebody remembered to add a path for; it now also reads the package directory and asserts every module declaring a `@router` route is imported by `__init__.py` — the C1 trap where a missing import mounts nothing while every direct-call test still passes. Verified by deleting the import and watching it fail. **m BUILT 2026-08-07** (mig `156_projects_tags.sql`, `routes/projects/tags.py`, `lib/tags.ts` + the panel picker, the filter row, a `tag` board axis and the Tags dialog; 31 hermetic + 37 vitest cases, 16 mutants red, 30 checks against a REAL Postgres) — the row the research notes left open ON PURPOSE: `paca_pm_research_2026-08.md` row 13 REFUSED Paca's model (*"a bare jsonb string array on tasks. No registry, no colors, no rename/merge — the weakest part of Paca's model"*) and §5 shipped `pm_tasks.tags TEXT[]` in its place with a registry named as additive later. **The array STAYS** — a join table would add a row per tag per task and a join to every board paint, to buy referential integrity this app enforces in one place, whereas the array arrives with the task and its GIN index (146) already answers "tagged X". What the registry buys: **one spelling per tag** (identity is case-INSENSITIVE via a unique index over `lower(name)` so two racing requests cannot create both, display is case-PRESERVING, and the task's array stores the REGISTRY's form — which is what makes "filter by bug finds all of it" true rather than aspirational, and what lets a rename be one statement); **rename**; **merge**; and a **colour**. **Applying an unregistered tag REGISTERS it** — refusing would make tagging a two-step errand (leave the task, create the tag, come back), which is how tagging gets abandoned, and an abandoned tag set is worse than a messy one. **The cost is stated: every typo becomes a tag** — which is exactly why merge is here and is not optional, and why the picker SHOWS the moment of creation rather than minting silently. **A rename onto an existing name is a 409, not a silent merge**: different operations, different outcomes, and one of them destroys a tag — quietly doing the destructive one because the names collided is what stops people using a rename button. **A task carrying BOTH tags ends a merge with the target ONCE** — the case that is easy to get wrong, and getting it wrong leaves a duplicate that renders twice and survives the next merge too; `merged_tags` is pure so that case is asserted directly, and the rewrite runs over affected rows in Python rather than as an `array_replace`, which would leave the duplicate. **TWO tag filters** because both questions get asked and neither answers the other: `tags` is ANY (`&&`) and `tags_all` is ALL (`@>`); collapsing them would silently pick a meaning, and with three tags the answers differ by almost everything. On the board a task with three tags appears in three columns, the same honesty as two assignees appearing in both theirs. **The migration backfills AND rewrites data, deliberately and narrowly:** `tags` has been on `pm_tasks` since 146 and the import path writes them, so an empty registry beside a tagged corpus means the first rename finds nothing; the winning display form is **the spelling people actually use** (most frequent, ties broken deterministically — `min()` alone would canonicalise 400 "Bug" to a single stray "BUG"), and task arrays are then made to agree, only ever swapping one CASING of a tag for another, with the count reported in a NOTICE. **A bug the live run caught in that block:** the canonicalisation used the implicit-comma `FROM pm_tasks t, unnest(t.tags) ... LEFT JOIN` form, where the LEFT JOIN binds only to `unnest(...)` and `t` is not in scope for its ON clause — the migration aborted with "invalid reference to FROM-clause entry for table t". Rewritten as `CROSS JOIN LATERAL … WITH ORDINALITY`, which also fixed a second problem the first version would have shipped: `array_agg(DISTINCT ...)` sorts by its own expression, so every task's tag list would have come back alphabetised. **n BUILT 2026-08-07** (`routes/projects/bulk.py` → `POST /projects/tasks/bulk`, `lib/selection.ts`, `components/BulkBar.tsx` + checkboxes on board and list; 35 hermetic + 32 vitest cases, 16 mutants red, 34 checks against a REAL Postgres; **no migration**) — **the ticket §11.3 names as gating g, so that prerequisite is now satisfied.** It **reuses `automation.apply_task_patch` rather than growing a second writer**: that service exists because WS-27f needed an edit indistinguishable in validation from a human PATCH, and a bulk endpoint with its own field handling would be a third opinion about what a task edit is. Tags go through the same registry the panel uses, because the registry cannot be true if bulk is a second door into the array. **Status is named, never keyed — load-bearing here rather than stylistic**: a selection spans projects and a status id belongs to one root, so `status_id` for fifty tasks across three projects puts two thirds of them in a lane that is not theirs; it is a 422 with its OWN message, because it is the mistake somebody makes by copying a single-task PATCH body and "unknown field" would not explain why the thing that works on one task is refused on fifty. **Assignees and tags are ADD/REMOVE, never SET** — "assign these to Priya" means ALSO Priya, and a replace wipes every individual assignment the fifty tasks already carried; the destructive spelling is absent rather than discouraged. **Shape validated once, outcomes per task**, because those are different failures: an unsettable field is the same mistake for all fifty (422 before any write), while a status name present in one project and absent from another is a fact about THAT task — failing the batch for it makes a mixed selection unusable, which is precisely the selection somebody makes after an import. **An invisible task is SKIPPED, not an error** (R5: per-id reporting says exactly what a per-id 404 says, and aborting would let a caller probe for existence). **One transaction** — a re-triage that half happened is harder to recover from than one that did not, because nobody can tell which half. **Re-asserting a value is not a change** (`moved_people` is pure so the claim is asserted directly): fifty tasks already Priya's would each gain a timeline entry saying nothing, and a count nobody can trust is worse than no count. **ONE notification per person per batch, not one per task** — being handed fifty tasks should ring once and say fifty; fifty bells is a bell people turn off, which is WS-27j's own argument applied to the case that would have broken it. In the browser the selection is **pruned whenever the filter changes** (select forty, narrow to three, press Done must not act on thirty-seven nobody can see), a shift-click ranges over the board's ON-SCREEN order, a two-assignee card drawn in two columns counts once, and the outcome line names every category including the boring ones. **AND IT UNCOVERED A SHIPPED WS-27j BUG** (spec §11.12): `notifications.deliverable` probed only `project_clause('t.root_project_id')` while `core.task_visibility_clause` — whose docstring warns a second implementation "would drift the moment one is edited alone" — carries TWO ways in. So (1) anybody assigned work in a project they hold no grant on was judged undeliverable: they could OPEN the task, the assignment notified nobody, and the response told the assigner they could not see it — the silent assignment WS-27j exists to end, still open for the most common case in a grant-scoped app; and (2) scoping to `root_project_id` ALSO missed a grant made on a SUBPROJECT — the old test asserted that scoping on the argument that "probing `project_id` would miss a grant made on an ancestor", which is BACKWARDS, as a real-Postgres run showed: the closure is recursive and expands DOWNWARD. Fixed to use the shared clause; the test that encoded the wrong reasoning now records why it was wrong. **A fake collision the fix exposed:** `FakeDB` matched the rule-3 probe by substring, and the new clause embeds both `pm_task_assignees` and the closure's `UNION` — exactly the AUDIENCE branch's fingerprint — so `deliverable` got a list of assignees where it expected a visibility answer and four tests failed for a reason unrelated to the code under test. A fake that dispatches on substrings needs fingerprints that are SPECIFIC, not merely present + +**Corrections applied 2026-08-09:** +- a/b/d/e/f/i/j/k/l/m/n are MERGED TO MAIN (#390, #393, #394, #398 + fixes) — the 'BUILT (branch)' wording was stale +- the state cell's 'f dispatchable' contradicted the body's 'f BUILT 2026-08-06' — f is built and merged +- the WS-27j notifications.deliverable clause bug (probes project_clause instead of core.task_visibility_clause) is an OPEN defect recorded at spec §11.12, found by n's tests. diff --git a/ai-company-brain/specs/saas_multitenancy.md b/ai-company-brain/specs/saas_multitenancy.md new file mode 100644 index 000000000..46d0c01ae --- /dev/null +++ b/ai-company-brain/specs/saas_multitenancy.md @@ -0,0 +1,1882 @@ +# SaaS multi-tenancy — selling CommandCenter to other companies + +**Status:** Architecture of record (owner-requested 2026-08-08) · **Board row: `work_plan.md` §2 → WS-29 · Decision: D15** · **§11 is the dispatchable ticket list — start there; [`saas_multitenancy_implementation.md`](saas_multitenancy_implementation.md) is its child and holds the build shapes** · **Owner:** vjvarada · +**Supersedes:** `tenancy_and_visibility.md` §1 and §6 · **Verified against code:** 2026-08-08, +working tree at `b09093a` · **Updated 2026-08-09** (consolidation pass): §8 items 1–2 +ANSWERED (D18 — Core ₹600 + ₹300/module; ₹10 AI-action credit ~50% margin), the Mem0 +path-8 decision taken (D17, Option A), §5.1's cutover trigger ADOPTED, MT-1a's stale +`members.py` anchor corrected, and H1 scratch-verified (migrations 157–159 applied + +verified on a full-ladder replica; prod apply = PR #404 — see the handover's H1 result +block) + +> **This document re-takes a decision that was deliberately taken the other way.** +> `tenancy_and_visibility.md` §1 (owner-answered 2026-08-03) set the tenant boundary at +> **the deployment** — one VM, one database, one credential set per customer — and §6 put +> row-level multi-tenancy, an org switcher, and multi-org users explicitly out of scope. +> That document's own §6 states the procedure: *"If any of these is ever wanted, the +> correct move is to re-take the §1 decision first, in this document, with a date and a +> reason — not to build one of them as a side effect of an app ticket."* +> +> **The reason: the business model changed.** CommandCenter is being sold to external +> customers, priced **per module, per user, per month**, plus metered AI. That price point +> and one-VM-per-customer are arithmetically incompatible (§1.4). This document is the +> re-take. `tenancy_and_visibility.md` §1/§6 are amended to point here; **everything else +> in that document — the visibility ladder in §3, the project-grant decision in §4, the +> gap table in §5 — survives unchanged and is still binding.** Tenancy and visibility are +> different axes: tenancy is *which company*, visibility is *who inside that company*. + +**Purpose.** Answer four owner questions with decisions, not options: + +1. What is the tenant boundary, and how does the system scale? (§1) +2. How are modules sold and enforced per company? (§2) +3. How is LLM access resold and metered? (§3) +4. How are accounts, subscriptions and billing managed? (§4) + +§5 is the phased plan and §6 is what must be fixed **before the second tenant exists at +all** — those are correctness blockers, not features. + +--- + +## 0. What already exists (measured 2026-08-08) + +Read this first. Three of these findings are what make the recommendation below cheap +rather than a rewrite, and one is what makes it currently unsafe. + +| Fact | Anchor | Why it matters | +|---|---|---| +| **One engine, one session factory, one `get_db()`** on the gateway **request path**, plus **six enumerable non-request paths** (§0.1) | `packages/acb_common/acb_common/db.py:107-136`; `tests/unit/test_db_engine_seam.py` fails the build if a new `create_async_engine` appears outside its allow-list | **The single most important finding.** Tenant scoping installs at a *named, bounded* set of connection sites, not at 3,000 query sites. ⚠️ **Not literally one — read §0.1 before quoting "one seam".** §1.3 | +| **`EffectiveAccess.intersect()` already exists** and is already used to narrow an agent's access to its member's | `packages/acb_auth/acb_auth/permissions.py:366-374` | Module entitlements are an intersection with a mask. The mechanism is already written and already tested. §2.4 | +| **`/v1/chat/completions` is the single LLM choke point**, and `_emit_usage()` already computes tokens + cache stats + USD cost per call, including for streamed responses | `apps/services/gateway/gateway/routes/v1_compat.py`; `packages/acb_llm/acb_llm/client.py:552-612`, rebuilt-from-chunks path at `v1_compat.py:563-573` | Reselling AI is ~4 additions to a seam that already meters. It is not a new subsystem. §3 | +| **`organization_id` is on 3 of 143 tables** and is read by **zero** authorization decisions | `130_org_access_control.sql:56,86`; `138_…sql:42`; `tenancy_and_visibility.md` §1.1 | The retrofit is 140 tables — but see §1.3 for why that is a generated migration, not 140 tickets | +| **`provider_keys` is keyed `provider TEXT PRIMARY KEY`** — one key per provider for the whole box | `infra/postgres/08_provider_keys.sql:6-7` | Must become `(organization_id, provider)` before a second tenant. §6 | +| ⚠️ **Integration credentials reach agents through process-global `os.environ`**, and the code says so itself: *"`os.environ` is process-global, so under concurrent [runs]…"* | `apps/services/orchestrator/orchestrator/executor.py:4335-4411` (write at `:4388`, restore at `:4409`) | **This is the one hard blocker.** In a pooled process, tenant A's Zoho token is visible to tenant B's concurrently-running agent. §6.1 | +| **`require_llm_api_auth` accepts one shared box-wide token** (`LITELLM_MASTER_KEY` or the internal token) | `packages/acb_auth/acb_auth/deps.py:448-472` | There is no per-customer attribution at the LLM layer today. §3.2 | +| **Roles, permissions, per-user overrides, feature catalog, groups, invites, audit — all shipped** | `130_org_access_control.sql`, `packages/acb_auth/`, `routes/admin/` | The *intra*-company model is done and good. This document does not touch it. | + +Scale of the tree, for cost estimates below: **156 migrations · 143 tables · 209 gateway +Python files · ~142k Python LOC · ~149k TypeScript LOC.** + +### 0.1 The connection inventory — correction, 2026-08-08 + +> ⚠️ **The first draft of this document said "one engine, one `get_db()`" without +> qualification. That was overstated and is corrected here.** It is true of the gateway +> request path and false of the process as a whole. An implementer who took the +> unqualified claim at face value would bind the tenant in `get_db()`, see the request +> path work, and ship six unbound connection paths. + +Every path that opens a database connection, measured repo-wide. + +> ⚠️ **Corrected 2026-08-08, and the correction is the lesson.** This table +> originally listed **eight** paths and said "measured repo-wide". It was measured +> across `apps/` and `packages/` — which is where the seam ratchets scan, and +> therefore exactly where a blind spot cannot hide. **Two more live in +> `scripts/`**, and one of them writes tenant data. The inventory was wrong in the +> same shape as the thing it was documenting: a scan whose roots decide its +> answer. Rows 9 and 10 were found by *building* the ratchet, not by reading. + +| # | Path | Driver | Carries tenant data? | Tenant binding needed | +|---|---|---|---|---| +| 1 | `acb_common/db.py` — the shared async seam | SQLAlchemy/asyncpg | **Yes** — the whole request path | `SET LOCAL app.tenant_id` from the session | +| 2 | `email_ingestion/scheduler.py:160,545,578` | SQLAlchemy | **Yes** | Per-run binding from the job's org. Allow-listed in the seam test as *"separate process; per-run engines"* | +| 3 | `email_ingestion/inbound.py:271` | SQLAlchemy | **Yes** | Per-call binding. Same allow-list entry | +| 4 | `acb_graph/db.py:32` — entity graph | SQLAlchemy **sync** `create_engine` | **Yes** | Binding required. ⚠️ **The seam test only inspects `create_async_engine`, so this file is unguarded by it** | +| 5 | `acb_llm/key_store.py:83-108` | raw `psycopg` | Provider keys | Becomes per-org (§6.3) | +| 6 | `acb_llm/model_config.py:52-76` | raw `psycopg` | Model config | Becomes per-org (§6.3) | +| 7 | `acb_common/org_settings.py:55-81` | raw `psycopg` | Org settings | Already org-shaped; must bind | +| 8 | `acb_memory/mem0_client.py:99` | hands a conninfo to **Mem0's own** pgvector client | **Yes** — all memory | Binding must reach Mem0's connections, or memory is scoped by the scope string alone | +| 9 | `scripts/import_hr_people.py:177` | SQLAlchemy `create_async_engine` | **Yes — it UPSERTs people rows** | ⚠️ **Found 2026-08-08 while building MT-1c's ratchet, after this table claimed to be "measured repo-wide".** An operator script, outside `apps/` and `packages/`, so neither ratchet's scan roots saw it. Once phase-4 policies are on it will either fail or write **unowned rows**. Must bind a tenant from argv | +| 10 | `scripts/check_infra.py:40` | raw `psycopg.connect` | **No** — reads `pg_extension` only | Same blind spot, benign content. Disposition: healthcheck, no tenant needed — but it must be *recorded* as a decision, not left undiscovered | + +**This makes RLS more important, not less — and it is the reason to prefer RLS over +application-level filtering or `search_path`.** A policy is enforced by the *server*, +so it covers paths 4–8 no matter which driver opens them and no matter what any +future package forgets. And it **fails closed**: with `app.tenant_id` unset, +`current_setting('app.tenant_id', true)` is NULL, `organization_id = NULL` is NULL, and +the query returns **zero rows**. An unconverted path breaks loudly in testing instead of +silently serving another tenant's data in production. + +**Consequences for Phase 1 (§5), which are now explicit acceptance criteria:** + +1. All **ten** paths bind a tenant. Paths 2–4 bind from the **job's** org, not a session; path 9 from argv; path 10 is exempt-with-a-reason (no tenant data). +2. **Extend `test_db_engine_seam.py` to `create_engine` as well as + `create_async_engine`** — path 4 exists today precisely because the ratchet does not + cover the sync call. +3. Add a companion ratchet for **`psycopg.connect`**, with the same allow-list-with-a-reason + discipline. Paths 5–7 were invisible to the existing test. +4. **Mem0 (path 8) is the genuinely awkward one** — the connection is opened by a + third-party library from a conninfo string. **DECIDED 2026-08-09 (D17, + `agent-proposed, owner may overrule` — `work_plan.md` §3): Option A, bind via + connection options** (`options=-c app.tenant_id=` on the conninfo Mem0 + receives; shapes in `saas_multitenancy_implementation.md` §2.4). The alternatives, + kept for the record: bind via connection options in the + conninfo, or give Mem0 its own tenant-scoped database role per tenant, or accept that + memory isolation rests on the scope string and pin that decision here. **Do not leave + it undecided.** + +--- + +## 0.9 THE TARGET, stated without reference to what exists + +Owner question, 2026-08-08: *disregarding the cost of migrating the current database — we +can start a new one — what is the right multi-tenant architecture for CommandCenter?* + +Answered here **before** §1, because §1 onward reasons from the existing tree and a reader +should be able to see the destination without the retrofit argument attached to it. + +### 0.9.1 The thesis — the interesting boundary is not the database + +Almost every multi-tenancy discussion is a database discussion. **For CommandCenter that +is the wrong emphasis, and it is wrong for a reason specific to this product:** + +| Ordinary SaaS (Slack, Notion, a CRM) | CommandCenter | +|---|---| +| Code paths are written by your engineers | **Agents execute model-generated tool calls** | +| Input is typed by authenticated users | **Input arrives from email and WhatsApp** — adversarial by default, prompt injection is a routine event, not an exotic one | +| The app reads and writes rows | Agents **write and run code** (App Workshop, self-mutation) | +| A breach exposes one product's data | A breach exposes **the company** — mail, CRM, finance, HR, meetings, all of it | + +The database can be defended by a mechanism that cannot be forgotten: a server-enforced +RLS policy. **The agent runtime has no equivalent.** No policy engine constrains what a +model decides to do with the tools it holds. + +> **Therefore: spend the isolation budget on the execution plane, and let the data plane be +> pooled behind a policy.** The instinct that led to "a container per customer" is sound — +> it is simply pointed at the wrong layer. Put the container around **the agent run**, +> not around the database. + +This is also the honest answer to why the pooled-vs-silo argument has felt unsatisfying +throughout this document: **both options isolate the layer that was already the easier one +to isolate.** + +### 0.9.2 Three planes, three different tenancy models + +| Plane | Holds | Tenancy model | Why | +|---|---|---|---| +| **Control** | organizations, identities, placement, entitlements, subscriptions, usage, credit ledger | **Shared, cross-tenant by design.** No RLS — it must read across tenants | It is the operator's view. Never holds tenant business data, so a compromise exposes contracts, not customers' mail | +| **Data** | email, CRM, tasks, projects, people, meetings, memory, apps | **Pooled Postgres, `organization_id` in every PK, FORCE RLS, per-tenant envelope encryption for sensitive columns** | §1 and §1.1a. The mechanism cannot be forgotten and fails closed | +| **Execution** | agent runs, ingestion jobs, app runtimes, mutation, meeting bots | **Ephemeral per-run sandbox, tenant-affine worker pools** | §0.9.3. This is where the money goes | + +### 0.9.3 The execution plane — the part worth building properly + +**The contract, and it is the whole design:** + +> **An agent run receives (a) one tenant binding, (b) only the credentials that run needs, +> issued for that run and expiring with it, and (c) no database connection at all. It +> reaches data exclusively through a tenant-bound API. Its egress is allowlisted. The +> sandbox is destroyed when the run ends.** + +Each clause closes a specific hole: + +- **No ambient credentials** — kills the class where a compromised agent reads secrets + belonging to work it was not doing. Today `executor.py:4388` writes them into + process-global `os.environ` (§6.1), which is the exact opposite of this clause. +- **No database connection** — an agent that cannot open a connection cannot escape RLS, + cannot set `app.tenant_id`, and cannot be SQL-injected into another tenant. This is what + makes a pooled data plane defensible *given* model-generated tool calls (§1.8a). +- **Allowlisted egress** — a successfully injected agent that can reach any URL can + exfiltrate whatever it legitimately holds. Isolation without egress control is theatre. +- **Ephemeral** — no state carries from one tenant's run to the next. + +**Implementation, in ascending order of strength:** container per run with seccomp and +no-network-by-default → gVisor → Firecracker microVMs. **Clean slate, start at the first +and design so the third is a swap, not a rewrite.** Keep per-tenant warm pools for +start-up latency; the pool is an optimisation and must never become the isolation +boundary. + +> ⚠️ **This inverts a live owner decision and the inversion is the point.** WS-3's T2 tier +> is **parked** (D10, 2026-08-03) on the explicit ground that *"the ladder must hold +> against trusted colleagues, not hostile users."* **Selling to external customers +> replaces that threat model.** Un-parking T2 is not an optional hardening item under this +> architecture — it *is* the architecture. P5-a (per-run credential scoping) already +> shipped, which means the hardest conceptual piece exists; what is parked is the +> enforcement tier above it. + +### 0.9.4 Fewer datastores — a clean-slate simplification worth taking + +Today: Postgres + pgvector, Redis, Neo4j, Langfuse, plus filesystem workspaces. **Every +additional datastore is another place tenancy must be enforced and another place it can +be forgotten** (§1.9's table is that cost, itemised). + +Clean slate: + +- **Drop Neo4j.** Neo4j Community offers one database and no real multi-tenancy, so the + graph becomes a tenancy problem with no good answer. An edge table in Postgres with + recursive CTEs covers the entity/memory graph at this scale, and inherits RLS for free. + **Removing a datastore removes a boundary** — that is a security improvement, not just + an ops one. +- **Blobs to object storage**, `/…` prefixed, per-tenant keys — never `BYTEA` + (§1.6). +- **Redis stays, but tenant prefixing is enforced by a wrapper client**, not by + convention. A convention is a thing people forget; a client that cannot construct an + unprefixed key is not. +- **Vectors get per-tenant namespaces** (partition or separate index per large tenant). + HNSW is the memory-hungry structure and the one place per-tenant physical separation + earns its cost on merit (§1.6). + +### 0.9.5 Identity, resolution and placement + +- **Global `user_identity` (email unique) + `org_membership`** from day one. Multi-org is + not a future feature: your own support staff need it on day two, and partners and + consultants on day thirty (§1.5). +- **Tenant from the authenticated session or a tenant-scoped API key. Never a header, + query parameter or body field** (§1.5's binding rule). +- **Subdomain per tenant** for the workbench. +- **A `tenant_placement` indirection from day one**, even when every tenant resolves to + the same target. It costs one table and one lookup, and it is what turns "move this + customer to their own database" from an architecture change into a data move — which is + what makes the silo tier, the competitor objection (§1.8a) and version pinning (§1.4b) + all answerable with the same mechanism. +- **Evaluate an external IdP** (WorkOS, Clerk, Keycloak) rather than growing this + yourself — SAML and SCIM arrive with the first enterprise deal (§1.8a). + +### 0.9.6 The three invariants + +Everything above collapses to three lines. If a design question is ever unclear, resolve +it against these: + +> 1. **The tenant is derived from the authenticated principal — never from input.** +> 2. **No code path reaches tenant data without a tenant bound, and the *database* +> enforces that, not the developer.** +> 3. **Agents hold no ambient authority** — no ambient credentials, no database +> connection, no unrestricted egress. Everything per-run, scoped, and expiring. + +Invariants 1 and 2 are ordinary good multi-tenancy. **Invariant 3 is the one this product +lives or dies on**, and it is the one an ordinary SaaS architecture would not tell you to +write down. + +### 0.9.7 Build order, clean slate — and what NOT to build + +1. Control plane + identity + tenant resolution, **with placement indirection from day one** +2. Data plane: RLS, `organization_id` in every PK, partitioning on the heavy tables, + object storage for blobs, envelope encryption for secrets and sensitive columns +3. **Execution plane sandbox contract** (§0.9.3) — before the first external tenant, not after +4. Entitlements + feature flags (§2, §1.4b) +5. Metering + credits (§3) +6. Billing automation (§4) + +**Do not build, clean slate or otherwise:** Kubernetes (Docker Compose on a few VMs until +it genuinely hurts — a small team's scheduler is a distraction, not a capability), Citus or +any sharding layer (adopt the distribution-key *discipline*, not the technology, §1.8a), +a service mesh, or a microservice split. **The monolith is correct here**; what needs +splitting is the **three planes' data and trust boundaries**, not the deployment topology. + +### 0.9.8 How much of this the phased plan already reaches + +Stated so the clean-slate answer and §5 are not read as two different plans: + +| Clean-slate element | In §5? | +|---|---| +| Control plane separate from tenant data | ✅ Phase 1 (§1.5) | +| Pooled + RLS + org_id in PKs | ✅ Phase 1 | +| Partitioning, object storage for blobs | ✅ Phase 1 (§1.6) | +| Envelope encryption | ◐ Phase 5, pull into Phase 1 if touching those columns (§1.1a) | +| Identity/membership split, placement, subdomain | ✅ Phase 1 | +| Feature flags + release channel | ✅ Phase 2 (§1.4b) | +| **Execution-plane sandbox contract** | ⚠️ **Phase 0 covers only the credential half (§6.1). The sandbox tier is WS-3 T2 and is currently PARKED.** This is the single largest gap between the phased plan and the target | +| Drop Neo4j, wrapper-enforced Redis prefixes, per-tenant vector namespaces | ❌ Not in §5. Cheap now, expensive later — **fold into Phase 1** | + +**The honest summary:** the phased plan converges on the target for the data plane and +diverges from it on the execution plane. Given a genuinely clean slate, **build §0.9.3 +first and the database question mostly stops being interesting.** + +--- + +## 1. DECISION — the tenant boundary is a ROW, and the deployment is a placement + +> ### `Tenant = organization_id, enforced by Postgres RLS at the connection seam.` +> ### `Deployment = a placement decision (region / tier), not a tenant boundary.` +> *(owner-requested 2026-08-08)* +> +> Standard customers are **pooled**: one app fleet, one database, isolation enforced by +> the database itself. A dedicated database or a dedicated stack is a **priced tier** for +> customers who ask for it — the same code path, a different row in the tenant catalog. + +### 1.1 The question the owner asked, answered directly + +> *"Should we spin up new containers with a completely different database for each +> customer so that everything is isolated and separate?"* + +**No — not as the default.** Do it for the handful of customers who pay for it. + +The instinct is right about the *goal* (a customer must never see another customer's +data) and wrong about the *mechanism*. Container-per-customer buys isolation against a +threat that is not the real one, at a cost that breaks the price point. + +**The real leak vector in this system is the application, not the database engine.** +CommandCenter's dangerous surfaces are an agent with broad tool access, a missing +predicate in one of 209 gateway files, a prompt injection arriving through an ingested +email, and process-global credentials (§0). A separate Postgres container stops none of +those. A tenant-scoped connection that the *database* refuses to widen stops the first +two, and per-run credential scoping (§6.1) stops the fourth. Spend the isolation budget +where the leaks actually are. + +### 1.1a "One database for everyone" — where pooled systems actually get their safety + +Owner question, 2026-08-08: *isn't it dangerous that Google Workspace keeps every +organization in one database?* Recorded because the premise contains a category error +that is worth fixing permanently, and because the correction produces a Phase-5 item this +document was missing. + +**First, the premise.** Workspace is not "one database" in any physical sense. It is **one +logical namespace, physically sharded by customer across thousands of machines** — +Spanner and Colossus, with customer/domain as the partition key. *"Pooled" is a statement +about the schema, not about the hardware.* One customer's data occupies its own contiguous +key range on its own machines; it is simply addressed through one logical system rather +than N administratively separate ones. That is exactly what §1.8a's distribution-key +discipline buys, in miniature. + +**Second, the honest part: yes, pooling concentrates consequence.** A single +authorization bug in a pooled system is potentially every customer, where in a silo it is +one. That is real, and no amount of architecture argument makes it not real. + +**Third — and this is the load-bearing observation — Google's safety does not come from +its storage topology. It comes from two layers deliberately built because the storage is +pooled:** + +1. **One central authorization service that every product must ask.** Zanzibar stores + ACLs as `user U has relation R to object O` tuples and answers permission checks for + Drive, Docs, Calendar, Photos, Maps, YouTube and Cloud — **trillions of ACLs, millions + of checks per second, sub-10 ms p95, >99.999% availability**, published in Google's + 2019 paper. No product re-implements access control; there is exactly one place to get + it right, and it cannot be forgotten because there is no other way to answer the + question. +2. **Per-customer encryption keys underneath.** Google's storage layer splits data into + chunks and encrypts each with keys **separate from those used for other customers** — + and separate even from other chunks of the same customer's data. Pooled storage is + therefore not pooled *plaintext*: a compromise at the storage layer does not yield + readable cross-tenant data. + +> **The transferable rule: safety in a multi-tenant system comes from a single +> un-forgettable enforcement point plus a layer beneath it that fails safe — not from how +> many database processes are running.** Silo is one way to buy a weak version of that +> guarantee; a policy the database enforces is a stronger version, and it is the version +> that survives a developer forgetting. + +**What this changes in this document.** RLS (§1.3) is CommandCenter's Zanzibar-analogue at +its scale: one enforcement point, on the server, that no route can forget. **The second +layer is missing and is now a Phase 5 item:** + +> **Per-tenant envelope encryption for the sensitive columns** — integration credentials, +> provider keys, message bodies, transcripts — with a per-tenant DEK wrapped by a master +> KEK. It makes a raw storage or backup compromise tenant-scoped rather than global, which +> is the specific residual risk pooling introduces and the only one silo genuinely +> answered. **Retrofitting encryption to populated columns is materially harder than +> adding it at rest-write time**, so if any of these columns are being touched during +> Phase 1, do it then instead. + +**What it does not change.** The comparison in §1.4 stands: silo shrinks one category of +bug, does nothing about the categories that cause most real breaches (session and +credential handling, SSRF, dependency compromise, a phished admin, an exposed backup), and +adds one of its own — **wrong-database routing, plus N versions of the access-control code +in production** (§1.4). Concentrated consequence is a real cost, paid for with a +lower probability of the bug occurring at all. + +### 1.2 How the companies you named actually do it + +The pattern is consistent across all of them, and it is the opposite of +container-per-customer: + +| Company | Tenant boundary | Deployment boundary | +|---|---|---| +| **Salesforce** | `OrgId` column on shared tables; one shared, metadata-driven schema serving 150k+ tenants. The canonical proof that pooled scales. | Regional "instances"/pods. A customer is *placed* on a pod; they do not get one. | +| **Microsoft 365 / Entra** | Entra **tenant ID**. Users, licences and policy all key off it in a shared directory service. | Regional scale units and forests. Dedicated stacks exist only as **sovereign/government clouds** — top of the price list, not the default. | +| **Google Workspace** | Customer ID / verified domain. Gmail, Drive and Calendar are massively pooled systems; your company's data is a partition key, not a server. | Data-residency *policy* on a pooled fleet (Assured Controls), not a per-customer deployment. | +| **Zoho** | Pooled per data centre. The customer's choice is *which DC* — US, EU, IN, AU. | The DC is the placement. Zoho One's per-module licensing (§2) rides on top of that pooled base. | + +**The rule they all follow:** *the tenant is a row-level concept; the deployment is a +region/tier concept.* Nobody at scale gives a 10-seat customer their own database, +because the marginal cost of a small customer must be near zero or the SMB tier cannot +exist. + +The industry names these shapes **pool / bridge / silo** (AWS's SaaS terminology). The +2026 consensus for B2B SaaS is: **pool for the standard tier, bridge or silo for +enterprise customers who pay for it.** + +### 1.3 Why this is affordable HERE — the seam that changes the arithmetic + +`tenancy_and_visibility.md` §1.2 rejected row-level tenancy on the grounds that it would +*"put a `WHERE organization_id = ?` on 111 tables and every query in the gateway."* +**That objection is wrong, and the reason is `acb_common/db.py`.** + +Because the connection sites are a **bounded, named set of eight** (§0.1) rather than +3,000 query sites, tenancy installs at those eight plus three structural changes — and +**zero existing `SELECT`/`INSERT` statements are rewritten**: + +**(a) One migration, generated — not 140 hand-written ones.** +```sql +ALTER TABLE ADD COLUMN organization_id UUID + NOT NULL DEFAULT current_setting('app.tenant_id', true)::uuid + REFERENCES organization(id) ON DELETE CASCADE; +ALTER TABLE ENABLE ROW LEVEL SECURITY; +ALTER TABLE FORCE ROW LEVEL SECURITY; -- ← without this the owner bypasses it +CREATE POLICY tenant_isolation ON USING ( + organization_id = current_setting('app.tenant_id', true)::uuid +); +CREATE INDEX _org_idx ON (organization_id); +``` +The column **default** is what means `INSERT` statements do not change either. The +existing single org backfills every row. + +**(b) One seam edit.** `get_db()` binds the tenant onto the session: +```python +async def get_db(tenant_id: str | None = None) -> AsyncSession: + s = get_session_factory()() + await s.execute(text("SET LOCAL app.tenant_id = :t"), {"t": tenant_id or _ctx_tenant()}) + return s +``` +> ⚠️ **`SET LOCAL`, never `SET`.** The pool recycles connections across requests +> (`pool_size` + `max_overflow`, `db.py:114-120`). A session-scoped `SET` survives the +> connection's return to the pool and becomes a cross-tenant read on the next borrower. +> `SET LOCAL` is transaction-scoped and resets on commit/rollback. This is the single +> highest-consequence line in the whole migration and it needs its own test. + +**(c) One role change.** The app must connect as a **non-owner, non-superuser** role. +Postgres RLS is bypassed by superusers, by `BYPASSRLS`, and by the table owner unless +`FORCE ROW LEVEL SECURITY` is set. Migrations keep running as the owner; the gateway +gets `acb_app`. + +**(d) One build-failing test**, in the spirit of `test_db_engine_seam.py`: enumerate +`pg_tables`, assert every application table has `organization_id`, `FORCE` RLS, and a +policy. A table added tomorrow is covered without anyone remembering — the same +by-construction discipline root `AGENTS.md` constraint 10 already applies to auth. + +That converts a 6-month rewrite into roughly **3–4 weeks**. It is still the largest +single piece of work in this document, and §1.5 lists what it does *not* cover. + +### 1.4 Why container-per-customer fails this business — priced at 10–50 seats + +> **Owner input, 2026-08-08: every customer is a company of 10–50 users**, not an +> individual. This section was first written against a 10-seat single-module customer and +> **its lead argument does not survive that input.** Corrected here rather than quietly +> left standing, because the corrected version is what makes the recommendation honest. + +**What no longer holds — the infrastructure-cost argument.** A 25-user company on three +modules at ~₹500/user/module is ~₹37,500/month (≈$450). A VPS able to run a full stack is +~$30–40/month — roughly **8% of revenue**. That is affordable. The original claim that +*"the SMB tier does not exist under this model"* was priced for a customer a quarter this +size and **is withdrawn.** At this ACV, dedicated infrastructure is not what breaks. + +**What holds, and holds harder — the cost that scales in people, not servers.** + +- **156 migrations × N customers × every deploy.** This is the binding constraint and it + gets *worse* as the customer base grows, because it is linear in N and paid by a small + team every week. At 20 customers, deploy babysitting, per-box backup verification and + N incident surfaces realistically consume **half an engineer** — permanently. +- **N boxes means N versions of your access-control code in production.** A migration that + fails on customer 14 leaves customer 14 running the old permission check. With + `130_org_access_control.sql` and its successors defining who can see what, **version + skew is a security defect, not an ops annoyance** — and it is a defect that pooled + cannot have. +- **Self-service signup is impossible.** Onboarding becomes DNS, TLS, systemd units and + credential sets — `tenancy_and_visibility.md` §1.2 priced it at *"roughly a day of + owner-gated work and a permanent second thing to patch."* +- **Cross-tenant product features need fan-out across N databases** — the Operator Console + (§4.1), aggregate usage, benchmarks, a shared agent marketplace. + +**The crossover, stated as a number so the decision is checkable.** Silo's cost is +**linear in customers**; pooled's is a **one-time 4–5 weeks** (§5 Phase 1). They cross at +roughly **8–12 customers**. Below that, silo is genuinely cheaper *and* faster to revenue. +Above it, silo compounds. See §5.1 for what to do with that. + +**Where silo is still right:** a customer with a genuine regulatory or contractual +requirement, paying for it, onboarded by hand — and the first handful of customers, as a +deliberate bridge (§5.1). Price it. Don't build the product on it. + +### 1.4b Customers on different versions — the one requirement that could overturn §1 + +Owner question, 2026-08-08. **This is the strongest argument for silo raised so far**, and +it is strong because it attacks §1.4a's own test: *who controls the upgrade cadence?* If +the answer becomes "the customer", the test points at silo and this document must follow +its own reasoning rather than defend its conclusion. + +**The phrase covers four different requirements with four different answers. Establish +which one is meant before designing anything.** + +| What "different versions" means | Answer | +|---|---| +| **A. Staged rollout / canary** — A gets v2.1 this week, B next week, everyone converges | **Pooled, unchanged.** Standard practice; needs expand/contract migrations (below) | +| **B. Release channels** — a customer chooses "give me changes two weeks late" | **Pooled, unchanged.** This is what Google Workspace ships as Rapid vs Scheduled Release: same code, same storage, admin picks the channel | +| **C. Per-customer configuration/features** — A has modules, fields, workflows or agents B does not | **Pooled, and already solved** — see below. This is the case owners usually mean | +| **D. Genuine version pinning** — A stays on v1.8 for a year because it was validated and must not move | **Silo tier. Pooled cannot do this**, and no amount of engineering makes it | + +**Why pooled handles A, B and C — and exactly where it stops.** + +> **Multiple *code* versions against one database: fine.** Every blue/green deploy, canary +> and rolling update already runs N code versions against one schema simultaneously. +> **Multiple *schema* versions in one database: impossible.** One database has one schema. + +The discipline that makes A and B safe is **expand/contract** (parallel change), and it is +non-negotiable once two versions run at once: add the column nullable → deploy code that +writes both old and new → backfill → deploy code that reads new → drop the old **only +after every running version has passed the read step**. Additive-only, never rename in +place. Where two versions genuinely need different shapes of the same data, a **view per +version** over one physical table buys more room. That comfortably supports **two or three +adjacent versions over a window of weeks**. It does not support eighteen months of drift — +that is case D. + +**Case C is already built, and this is the finding that matters most.** CommandCenter's +per-customer variation is **data, not code**, across the board: + +- **Custom Apps** — `114_custom_apps.sql` + `app_files`: apps are DB rows, not deployed code +- **Workflows** — root `AGENTS.md` is explicit that they are *"DB-persisted configuration + orchestrating code-authored agents"* (ADR-028), the sanctioned exception to no-in-app-authoring +- **Dynamic agents** — `15_dynamic_agents.sql`: registered and persisted, not compiled in +- **Custom fields** — `pm_custom_fields` + `custom_fields JSONB` (`155_…sql:28,80`) +- **Org settings** — `organization.settings JSONB` (`130_…sql:42`), plus `config JSONB` + on workflows, plugins, projects and agents + +> **The platform's whole design premise is that customers extend it with data rather than +> with forks.** Per-tenant data is exactly what a pooled database is good at. **Do not +> reach for per-customer code versions to deliver something the configuration layer +> already delivers** — that trades a solved problem for an unsolved one. + +**What customers actually want when they ask for "our own version".** Almost always: +*"don't change things under me without warning."* That is a **release channel plus feature +flags**, not a code fork — and it is why no major SaaS offers version pinning while all of +them offer rollout control. **Add a feature-flag layer** (per-org, per-feature, evaluated +beside the entitlement mask in §2.3, since it is the same shape of lookup) and cases A–C +are covered without touching tenancy. **This is now a Phase 2 item.** + +**If the requirement is genuinely D.** Then it is real and it is expensive, and both facts +should reach the customer: + +1. **Version-pinned customers go on the silo tier** (§1.5). This is that tier's **second + independent reason to exist**, alongside compliance and the competitor objection + (§1.8a) — three unrelated demands, one mechanism, which is a good sign the tier is + correctly drawn. +2. **Price it at what it costs.** Version pinning means a supported branch, backported + security fixes, and a separate test matrix — the cost structure that turns enterprise + software vendors into maintenance organisations. +3. **Cap it contractually**: current version plus one prior; older than that is upgrade or + lose support. **A cap written after the first pinned customer is a negotiation; written + before, it is a policy.** + +**When this overturns §1.** If D stops being the exception and becomes what most customers +buy, the §1.4a test has genuinely flipped — the customer controls the cadence, and +CommandCenter is on Hostinger's side of the line rather than WordPress.com's. **Re-take §1 +at that point.** Nothing in the phased plan (§5) is wasted if that happens: the silo +customers of §5.1 are already the mechanism, and every silo running the pooled schema is +what keeps both doors open. + +### 1.4a The WordPress analogy — why hosting and SaaS answer this differently + +Raised by the owner 2026-08-08, and worth recording because the intuition is common, +reasonable, and points the opposite way once followed through. + +Hostinger gives every WordPress install its own database. **That is correct for +Hostinger and irrelevant to CommandCenter, because Hostinger is a host, not a SaaS.** +The determining question is: + +> **Who controls the schema and the upgrade cadence — you, or the customer?** + +| | Customer controls the app | **You** control the app | +|---|---|---| +| Examples | WordPress on shared hosting · self-hosted Odoo · Jira Data Center | Salesforce · Google Workspace · Slack · Zoho · **CommandCenter** | +| Consequence | The host cannot know or migrate the schema; customer A may run WP 5.8 while B runs 6.4; the customer installs arbitrary plugins that alter tables | You ship one version to everyone; customers cannot fork the schema or install plugins into your Postgres | +| Correct model | **Database per install — mandatory** | **Pooled — the norm** | + +**WordPress's own answer, when WordPress is the SaaS, is not database-per-customer.** +WordPress Multisite puts every site in **one database**, adding a per-site *table prefix* +(`wp_2_`, `wp_3_`, …) over a set of shared network-wide tables — users among them. And at +WordPress.com scale the fix was **hash-based sharding into 16 / 256 / 4096 shards**, not a +database per site. + +Two things follow, and both support this document's decisions: + +1. **Same software, different business model, different answer.** Hostinger silos because + the customer owns the install. WordPress.com pools because WordPress.com owns it. You + own CommandCenter. You are on the WordPress.com side of that line, not Hostinger's. +2. **Multisite's per-site table prefix is schema-per-tenant in a different costume — and + it hits exactly the failure §1.8 predicts.** Per-site table sets multiply the catalog + (a 1,000-site network is tens of thousands of tables), which is *why* large networks + shard. That is independent real-world confirmation of §1.8's catalog-pressure argument, + arriving from the very example that seemed to argue the other way. + +### 1.5 The target architecture, concretely + +**Three tiers, one codebase.** The tenant resolver returns `(organization_id, +connection_target)`; everything downstream is identical. + +| Tier | Data | Compute | Onboarding | Who | +|---|---|---|---|---| +| **Standard (pool)** | Shared Postgres, RLS | Shared fleet | Self-service, seconds | ~95% of customers | +| **Dedicated data (bridge)** | Own Postgres DB (or own schema) | Shared fleet | Semi-automated, hours | Compliance-sensitive mid-market | +| **Dedicated stack (silo)** | Own everything | Own VM/namespace | Manual, days | Enterprise, regulated, data residency | + +**The tenant catalog.** A small **control-plane database, separate from tenant data**, +holding: `organization`, `tenant_placement` (which shard/DB/region), billing, entitlements +and usage. It must be readable *across* tenants — which is exactly what RLS is designed to +prevent — so it does not belong in the pooled tenant DB. It also has a different backup +and retention profile, and keeping revenue data out of the tenant DB means a tenant-side +compromise does not expose every customer's contract. (Microsoft's sharded-multitenant +reference architecture calls this the catalog database; it is a standard component, not an +invention.) + +**Tenant resolution — subdomain, bound to the session.** +`acme.commandcenter.app` → workbench middleware resolves the slug → the **session** carries +the tenant claim → the gateway reads it from the authenticated identity. + +> **Binding rule, extending `user_management_contract.md` rule 10** (*"never take the +> acting identity from a query parameter or request body"*): **never take the acting +> tenant from a header, query parameter or request body either.** The tenant is derived +> from the authenticated session or from a tenant-scoped API key (§3.2), and from nowhere +> else. An `X-Organization-Id` header that the client can set is a one-line +> cross-tenant read. (`multi_user_organization_research.md` §17.3 proposes exactly that +> header — **that proposal is rejected here.**) + +**Multi-org users become supported.** `tenancy_and_visibility.md` §6.3 ruled them out +because `app_user.email` is globally unique. For SaaS this must change: partners, +consultants, and *your own support staff* need to be in more than one tenant. The standard +shape (Clerk, Auth0, Slack, Google Workspace all converge on it): + +``` +user_identity(id, email UNIQUE, name, …) -- global, one row per human +org_membership(user_id, org_id, status, …) -- the tenant-scoped membership +``` +Today's `app_user` becomes `org_membership`; the email-keyed columns across the schema +(`app_grants.subject`, `apps.owner_email`, `gtd_items.user_id`, `meeting.owner_email`, …) +stay email-keyed and become correct automatically, because RLS already constrains the row +set to one tenant. **That is a second reason to do RLS first** — it makes the identity +split cheap instead of a re-key of 31 columns. + +**What is NOT the tenant boundary.** Centers/departments are *inside* a tenant and are +already answered by `tenancy_and_visibility.md` §3 — `private → Center → org`, expressed +as `email | group: | org`. **That ladder is unchanged and still binding.** A tenant +is not a Center; a Center is never a deployment. Do not introduce a third scoping doctrine +(§3.2's standing rule). + +### 1.6 Physical layout at multi-GB per tenant *(added 2026-08-08, owner question)* + +Pooling is a **logical** isolation decision. It says nothing about physical layout, and at +several GB per customer the physical layout is a separate design problem that must be +answered whichever tenancy model wins. Answered here so "pooled" is not mistaken for "one +undifferentiated heap". + +**Where the gigabytes actually are, measured:** + +| Store | Shape | Weight | +|---|---|---| +| **pgvector embeddings** | `email_embeddings.embedding vector(1536)` (`73_…sql:29`), `whatsapp_embeddings vector(1536)` (`111_…sql:31`), `transcript_segment.embedding vector(1024)` (`95_…sql:79`), `entity.embedding vector(1024)` (`01_schema.sql:86`), plus Mem0's own | **Dominant term.** A 1536-dim float32 vector is ~6 KB; with an HNSW index the on-disk cost is roughly double. 100k embedded emails ≈ **1–1.5 GB for one tenant's email index alone** | +| **`agent_blob.content BYTEA`** | Blobs stored **inside Postgres** (`71_agent_blob_store.sql:30`), plus a versioned history table | Grows without bound; the natural first candidate to evict | +| **Email bodies + FTS** | `email_messages` + GIN `to_tsvector` indexes (`72_email_search_fts.sql:31`) | Large, but ordinary relational data | +| **Meeting media** | `meeting_media.artifact_path TEXT` → filesystem (`NOTES_MEDIA_DIR`, `95_…sql:56`) | ✅ **Already outside Postgres.** Good — keep it that way | + +> **The reframe that matters:** for most tenants the "multiple GB" is **embeddings and +> blobs, not rows.** Move `agent_blob` to object storage keyed by `/…` and the +> relational working set per tenant drops to the hundreds of MB. **Do that regardless of +> tenancy model** — a BYTEA column is the wrong home for file content in any topology. + +**What actually constrains a single Postgres — and it is not total size.** Postgres runs +multi-TB routinely; 100 tenants × 5 GB is 500 GB, which is unremarkable. The three real +constraints are: + +1. **Working set vs RAM.** One instance with a large `shared_buffers` serves the union of + all tenants' hot pages better than N instances that each reserve their own and cannot + lend. This is the single strongest efficiency argument for pooling and it is the one + that container-per-tenant-on-one-VPS gets exactly backwards (§1.7). +2. **HNSW index memory.** The vector indexes are the memory-hungry part, and a pooled + index means every tenant's search shares one structure. **This is the one place where + per-tenant physical separation is worth considering on merit rather than on fear** — + see the partitioning rule below. +3. **Restore time (RTO).** A multi-TB `pg_restore` is measured in hours. This is a real + argument for keeping the pooled instance from growing unboundedly, and it is + independent of isolation. + +**Three rules that make pooled work at this data size:** + +- **Partition the heavy tables by tenant.** Declarative partitioning on `organization_id` + for `email_messages`, `email_embeddings`, `chat_message`, `audit_event` and the vector + tables. Partition pruning means a query for tenant A never touches tenant B's pages — + most of the locality and noisy-neighbour benefit of separate databases, inside one + instance. **Use LIST partitions for the few largest tenants and a HASH/default partition + for the long tail**; one partition per tenant across all tenants recreates the catalog + pressure that sinks schema-per-tenant (§1.8). +- **Per-tenant logical backup is a required capability, not a tenancy-model side effect.** + "Restore this one customer to yesterday" must be answerable, and in a pooled instance + `pg_restore` cannot answer it. Build a per-tenant logical export/import job in Phase 1. + Note this is the one genuine capability that database-per-tenant gives for free — and + buying it costs one job, not N databases. +- **Keep an eviction path.** `tenant_placement` (§1.5) is what makes a large tenant + movable: export, load into its own database, flip the row. **A tenancy model you cannot + reverse is the actual risk**, and this is the cheapest insurance against picking wrong. + +### 1.7 Rejected — one container per organization on the same VPS + +Considered explicitly (owner question, 2026-08-08) because it is a different proposal from +one VPS per customer and deserves its own answer. **It is the worst of the three options**, +and this is not a close call: + +1. **It fragments the one resource that matters.** N Postgres containers each hold their + own `shared_buffers`, WAL, autovacuum workers and connection slots, and **cannot lend + memory to each other**. Twenty containers on a 16 GB box get well under 1 GB of cache + each; one pooled instance gives the *union* of hot working sets the whole cache. At + multi-GB tenants with HNSW indexes (§1.6), this is decisive. +2. **It does not deliver the isolation it appears to.** Same kernel, same page cache + pressure, same disk queue. A tenant running a heavy import still starves the others on + IOPS. Container boundaries do not partition a shared spindle. +3. **It keeps the entire operational cost of database-per-tenant.** N migration runs, N + backup jobs, N restore procedures, N monitoring targets, N connection pools — all + unaffected by whether the containers share a VPS. +4. **It adds a failure mode neither other option has:** one box's resource exhaustion or + reboot takes down *every* tenant, so the blast radius is silo's ops cost with pool's + blast radius. + +**The honest summary:** dedicated containers only buy something when they are on +**dedicated hardware** — which is the silo tier in §1.5, priced accordingly. On shared +hardware they are ceremony. + +### 1.8 Rejected — schema-per-tenant *(the closest alternative; recorded properly)* + +This is the strongest option **not** chosen, and it was under-weighted in the first draft. +It deserves a real entry rather than a dismissal. + +**What is genuinely good about it:** one Postgres instance, so §1.7's memory-pooling +argument is preserved; `pg_dump -n ` gives per-tenant backup for free; moving a +tenant out later is mechanical; and the isolation story is easier to explain to a +procurement team than an RLS policy. + +**Why it still loses, on one decisive property:** + +> **RLS fails closed. `search_path` fails open.** +> +> With RLS, an unset or wrong `app.tenant_id` yields **zero rows** — a loud, obvious, +> immediate failure that surfaces in the first test. With schema-per-tenant, a wrong +> `search_path` yields **a complete, valid-looking result set belonging to another +> tenant** — silently, with no error, indistinguishable from correct behaviour until a +> customer reports seeing someone else's data. + +Both models concentrate the trust in one per-request binding. They differ entirely in what +happens when that binding is wrong, and for a system where §0.1 shows eight distinct +connection paths, the failure mode is the whole argument. + +Two secondary costs: **catalog pressure** — 143 tables × N schemas, where the practical +ceiling is in the low hundreds to low thousands of tenants before `pg_dump`, autovacuum +and query planning degrade — and **migrations run N times** (better than N instances, but +still N, against 156 files today). + +**Where it would win, stated so the call can be re-taken:** if the target is a few dozen +large customers rather than many small ones, catalog pressure never arrives, per-tenant +backup matters more than onboarding speed, and the procurement conversation is easier. + +> **Tested against the owner's answer, 2026-08-08 — the condition is NOT met.** +> 10–50 users per customer is **mid-market, not enterprise**: it is Slack's, Notion's, +> HubSpot's, Freshworks' and Zoho's core segment, and every one of them is pooled. The +> flip condition needs *few customers*, and 10–50 seats implies the opposite — a customer +> base counted in dozens-to-hundreds, where catalog pressure (143 tables × N schemas) does +> arrive and onboarding speed does matter. **Pooled stands.** Re-take this only if the +> plan changes to topping out at ~20–30 accounts at high ACV, which is a different +> business, not a bigger version of this one. + +### 1.8a Greenfield check — which arguments here are design, and which are retrofit + +Owner question, 2026-08-08: *would this still be the recommendation if it were not +anchored to what CommandCenter already is?* Recorded because a reader two years from now +must be able to tell **"we chose this"** from **"we inherited this"**, and because the +audit produced two changes to Phase 1. + +**Arguments that are pure design — they hold for any greenfield system with this customer +profile, and nothing in them depends on this tree:** + +- Pooled over silo for 10–50-seat B2B customers (§1.4). The comparison set — Slack, + Notion, HubSpot, Freshworks, Zoho — did not inherit anything from us. +- RLS over application filtering, on **fails-closed vs fails-open** (§0.1, §1.8). That is + a property of the mechanisms. +- Container-per-org on shared hardware being the worst option (§1.7) — resource arithmetic. +- Entitlements ≠ permissions (§2.1), credits not tokens (§3.2), assigned seats not active + users (§2.2). Three business principles with no code dependency. + +**Arguments that are retrofit reasoning, and must not be mistaken for design:** + +- *"The seam already exists"* — `get_db()`, `EffectiveAccess.intersect()`, `_emit_usage()`. + These make the migration cheap. **Greenfield they carry zero weight**, because greenfield + you simply write the tenant column into the first migration and the whole question + evaporates. +- **The 4–5 week Phase 1 estimate is entirely a retrofit number.** Greenfield, multi-tenancy + is roughly three days of schema discipline. ⚠️ **This is the largest single distortion in + the document:** the pooled-vs-silo debate is expensive *here* only because 143 tables were + built without a tenant column. It is not evidence that the decision is hard in general. +- **§3.1's "don't add a proxy" is ~60% retrofit.** Greenfield, buying an AI gateway + (LiteLLM, Portkey, Helicone) versus building metering into the app is close to a coin + flip. The one argument that survives greenfield is that a separate proxy must **re-resolve + the tenant**, creating a second boundary to get right, and that it lacks the app context + (which module, which agent) that per-module margin analysis needs. Buy it if the routing + and dashboards are worth more than that. **The conclusion is unchanged; the confidence + should be lower than §3.1 implies.** + +**Two things a greenfield design would include that this document did not — both cheap +now, both expensive later, and both therefore added to Phase 1:** + +1. **Treat `organization_id` as a distribution key, not just a filter column.** Put it in + every primary key and every index prefix, and colocate related tables on it. Costs + nothing today and is the precondition for sharding — Citus and every distributed + Postgres take tenant-id colocation as their flagship multi-tenant pattern. Retrofitting + a distribution key after the fact means rewriting every primary key. **Adopt the + discipline; do not adopt Citus, which is unnecessary complexity at this scale.** +2. **Evaluate an external identity provider for organizations, memberships and SSO** + (WorkOS, Clerk, Keycloak) rather than growing `app_user` into it. Enterprise B2B + eventually demands SAML and SCIM, and building those is a tar pit. This is a genuine + *"greenfield I would not build this myself"* — but note the honest counterweight: the + shipped RBAC (`org_access_control.md`) is good, and the migration cost may already + exceed the benefit. **Decide deliberately rather than by default.** + +**The argument this document under-weighted, and it is independent of the codebase:** +CommandCenter's agents execute model-generated tool calls over content ingested from +untrusted sources (email, WhatsApp). That is a **materially higher risk profile than +ordinary SaaS**, and it is a real point in silo's favour that §1.1 waved past. It does not +flip the decision — an injected agent already holds its own tenant's data, and RLS blocks +the incremental "read *other* tenants" step at the server — but it raises the bar on two +things that are now non-negotiable rather than merely advisable: + +> **No agent ever gets a raw-SQL tool, and no agent-reachable code path can set +> `app.tenant_id`.** The agent must inherit a session already bound by the request or job +> and must never open a connection of its own. If either of those is violated, pooled +> tenancy is not defensible and §1 should be re-taken. + +**The one go-to-market risk that no architecture answers:** if two customers are +competitors — plausible when selling manufacturing software from a manufacturer — *"is my +data in the same database as theirs?"* is a procurement question, and *"no, separate +database"* is a far easier answer than explaining a row-level policy. That is a **sales** +argument for the dedicated-data tier (§1.5), not a technical one, and it is the reason +`tenant_placement` and the eviction path (§1.6) earn their keep on day one. + +### 1.9 The surfaces RLS does NOT cover — decide each, or they leak + +Postgres RLS protects Postgres. These do not run on Postgres: + +| Surface | Today | Required | +|---|---|---| +| **Redis** | `cc:*` keys carry no tenant (`cc:activity`, `cc:room`, `cc:cost`, `cc:presence`, …) | Prefix every key `cc::…`; separate consumer groups per tenant on the Streams bus | +| **Background jobs** | Ingestion scheduler, reconciler, orchestrator runs — no request, so no session tenant | Every job carries an explicit `organization_id` and binds it before `get_db()`. **This is where pooled systems actually leak.** A job that forgets is unbounded, not one row wide. | +| **Neo4j / Graphiti** | Single Community instance, one database | Tenant property + mandatory filter, or (better) accept that Neo4j Community allows one DB and move the graph behind a tenant-aware service | +| **Agent workspaces / blobs** | Filesystem paths, `agent_blob.instance ∈ ''\|u:\|t:` | Tenant becomes the outermost path/prefix segment: `//…`; object storage (S3/MinIO) rather than VM disk | +| **Mem0 memory scopes** | `` · `prefs:` · `room:` · `agent:` · `org:global` | `org:global` is currently *deployment*-global. Must become tenant-scoped. Coordinate with WS-10 S1 — do not add a sixth scope shape independently. | +| **Langfuse / observability** | One project | Tenant tag on every trace, or a project per tenant | +| **Self-mutation** | Native-MAF agents open PRs against **this monorepo** (root `AGENTS.md` constraint 3) | **Hard-blocked for third-party tenants.** See §6.2. | + +--- + +## 2. DECISION — modules are ENTITLEMENTS, and entitlements are not permissions + +> ### `access = entitled(org, module) AND permitted(user, feature)` +> Two layers, two owners, evaluated in that order. Never conflated. + +### 2.1 Why the distinction is load-bearing + +CommandCenter already has a permission layer: `feature:whatsapp`, roles, per-user +overrides with deny-wins-by-specificity (`permissions.py`). That answers **"is this user +allowed?"** and its owner is the *customer's* admin. + +Entitlement answers **"did this company buy it?"** and its owner is **you**. + +Collapse them and two things break immediately: a customer's admin can grant themselves a +module they never paid for (they control the role table), and a downgrade at renewal has +to rewrite everyone's roles — losing the customer's own access configuration in the +process. Every mature per-module product (Microsoft 365 licences, Zoho One, Atlassian, +Salesforce feature licences) keeps these separate for exactly these two reasons. + +### 2.2 Schema — in the control-plane DB, not the tenant DB + +```sql +-- What you sell. A SKU, product-facing. +module_catalog( + slug TEXT PRIMARY KEY, -- 'crm', 'email', 'whatsapp', 'finance' + display_name TEXT NOT NULL, + feature_slugs TEXT[] NOT NULL, -- which feature_catalog rows it unlocks + requires TEXT[] NOT NULL DEFAULT '{}', -- e.g. finance requires core + is_core BOOLEAN NOT NULL DEFAULT false, -- always on, never sold separately + list_price_per_seat_month NUMERIC(12,2), + currency TEXT NOT NULL DEFAULT 'INR' +); + +-- What a company currently owns. The CACHE OF BILLING TRUTH, written by webhooks. +org_module_entitlement( + organization_id UUID NOT NULL, + module_slug TEXT NOT NULL REFERENCES module_catalog(slug), + state TEXT NOT NULL CHECK (state IN + ('trial','active','past_due','suspended','cancelled')), + seats_purchased INT NOT NULL DEFAULT 0, + effective_from TIMESTAMPTZ NOT NULL DEFAULT now(), + effective_until TIMESTAMPTZ, + source TEXT NOT NULL, -- 'stripe' | 'razorpay' | 'manual' + PRIMARY KEY (organization_id, module_slug) +); + +-- Which named user holds a seat. This is what "per module per user" means. +user_module_seat( + organization_id UUID NOT NULL, + user_id UUID NOT NULL, + module_slug TEXT NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + assigned_by TEXT, + PRIMARY KEY (organization_id, user_id, module_slug) +); +``` + +**Why an explicit seat assignment rather than counting active users.** This is the +Microsoft 365 model and it is the right one for a per-user-per-module price: + +- The invoice is **explainable and predictable** — "you assigned 12 CRM seats" beats "13 + people opened CRM in June, one of them once." +- **Unassigned seats are visible**, to you and to the customer. "You are paying for 3 + unassigned CRM seats" is a retention conversation; "you have 4 users without WhatsApp" + is an upsell. Active-user billing surfaces neither. +- It is **auditable**. Assignment is an act with an actor and a timestamp; usage is a + side effect. + +**Never bill on active users.** Customers cannot forecast it, so they distrust it, and +every quiet month becomes a support ticket. Put predictability in seats and variability in +metered AI (§3) — that is the hybrid model the market has settled on. + +### 2.3 Enforcement — one seam, zero route edits + +`EffectiveAccess.intersect()` **already exists** (`permissions.py:366-374`) and already +does exactly this job for agents ("an agent acts on behalf of a member and must never +exceed them"). Entitlements are the same operation with a different mask: + +```python +effective = role_and_override_access.intersect(entitlement_mask(org_id)) +``` + +Compute `entitlement_mask` once per request from `org_module_entitlement` (cached in +Redis, invalidated by the billing webhook — never a Stripe call on the request path), and +**every existing `require_permission("feature:crm")` call site and the entire nav gating +inherit entitlement enforcement with no route changes.** Same trick as §1.3: find the one +seam. + +**Distinguish the two failures on the wire:** +- **403 Forbidden** — you are signed in, your org owns this module, your admin has not + granted it to you. *Action: ask your admin.* +- **402 Payment Required** — your org does not own this module. *Action: upgrade.* + +`/auth/me` returns **both** `features` (what you may use) and `modules` (what the org +owns, with state and trial expiry) so the frontend can tell these apart. + +### 2.4 "Comprehensive even with a fraction of the modules" — the degradation contract + +This is the owner's real requirement and it is a **design rule**, not a feature. A locked +module must be **absent-but-legible**, never broken: + +1. **A locked module shows an upsell, not a 404.** `}>` in the workbench. A module the customer cannot see, they cannot + buy. This is a revenue lever, not a courtesy — it is how Zoho One and Atlassian + cross-sell. +2. **Cross-module surfaces degrade, never error.** The Company Center rolls up every + Center; with Finance unowned, the Finance tile renders empty-with-CTA. A CRM deal + linked to an email thread renders as plain text when Email is unowned. +3. **A `core` module is always on** — auth, chat, admin, dashboard shell, memory. The + product is never empty, and there is always a surface on which to sell the rest. +4. **Modules declare dependencies** (`module_catalog.requires`). Buying Finance without + Core is rejected at checkout, not discovered at runtime. +5. **Gate the non-HTTP surfaces too — this is the one people forget.** An unowned module + must not: register its agents, run its ingestion schedulers, consume its Redis streams, + or fire its workflow triggers. Otherwise the module is dark in the UI while its email + sync still polls every five minutes and **still costs you provider spend for a customer + who is not paying for it.** + +**Mapping today's features to sellable modules** (`FEATURES` at +`packages/acb_auth/acb_auth/permissions.py:73`, `feature_catalog` seeded by migrations +130 and 140): + +| Module | Features it unlocks | Note | +|---|---|---| +| `core` | chat, memory, dashboard, artifacts, settings | Always on | +| `email` | email + `center.marketing` mail surfaces | Heaviest ingestion cost | +| `whatsapp` | whatsapp | Per-number provider cost — price accordingly | +| `crm` | crm, `center.sales` | | +| `projects` | projects, tasks | | +| `people` | people, `center.people` | | +| `finance` | `center.finance` | Not yet built — the catalog row can exist before the module does | +| `notes` | notes, meeting bot | Per-minute STT cost | +| `automation` | workflows, approvals, observability | | +| `builder` | build.apps, build.agents | Highest-risk module; gate hardest | + +**Adding a module must stay a data change.** A new SKU is a `module_catalog` row plus a +`feature_catalog` row plus a `FEATURES` tuple entry — never a code path per customer. Note +today's trap, documented in the `FEATURES` docstring at `permissions.py:65-72`: a slug +seeded in SQL but missing from +the `FEATURES` tuple is **invisible even to an owner holding `*`**. Keep the pinning test. + +--- + +## 3. DECISION — resell AI through the existing `/v1` choke point, priced in CREDITS + +> ### `Do not reintroduce a separate proxy. The gateway's /v1 already IS the proxy.` +> ### `Sell internal credits, not provider tokens.` + +### 3.1 Why not a separate LLM proxy process + +The obvious move is to put LiteLLM Proxy (or similar) in front of everything and use its +virtual keys, team budgets and spend tracking — which are genuinely good features. +**Don't**, and the reason is in this repo: the proxy process was already removed +(`infra/litellm/config.yaml`: *"The gateway uses the litellm Python SDK directly (no proxy +process)"*), and `/v1/chat/completions` in `v1_compat.py` is now the documented choke point +*"every agent runtime POSTs through"* — already authenticated, already computing +per-call cost, already handling the streaming case. + +Adding a proxy back would create a **second** key store, a **second** database, and a +**second** place where tenant identity must be enforced correctly. You would be buying 80% +of something you have already built, at the cost of a second tenancy boundary to get +right. Keep metering in your own code, where the tenant is already resolved. + +*(If you would rather buy than build, LiteLLM's virtual-keys/team-budget model is the +right thing to buy and the design below maps onto it one-for-one — key → org, team budget +→ credit balance. Decide once; do not run both.)* + +### 3.2 The four additions + +**(1) Per-organization virtual keys — the load-bearing change.** +Today `require_llm_api_auth` accepts a single box-wide token (`deps.py:448-472`), so there +is **no per-customer attribution at the LLM layer at all**. Replace with: + +```sql +llm_api_key( + id UUID PK, organization_id UUID NOT NULL, prefix TEXT NOT NULL, -- 'cc_live_a8f3…' + key_hash TEXT NOT NULL, label TEXT, scopes TEXT[], + created_by TEXT, revoked_at TIMESTAMPTZ +); +``` +Match on `prefix`, verify the hash. **The key resolves the tenant**, and everything +downstream — budget gate, metering, model policy, rate limits — hangs off that one +resolution. Nothing else in §3 works without it. + +**(2) Pre-flight budget gate — in Redis, not Postgres.** +Before the provider call, check the org's balance against a Redis counter and reject with +**402** if exhausted. This is on the hot path of every token; Postgres is the ledger, +Redis is the gate. Include a **per-run spend circuit breaker**: an agent in a tool loop can +burn a large amount in minutes, and this codebase has retry loops and a 32k default output +ceiling (`v1_compat.py:_DEFAULT_MAX_OUTPUT_TOKENS`). + +**(3) Post-flight metering — `_emit_usage` already has the numbers.** +`client.py:552-612` already computes prompt/completion/cached tokens and USD cost, and +`v1_compat.py:563-573` already rebuilds usage from streamed chunks. Add: write a +`usage_event` row and decrement the Redis counter. + +```sql +usage_event( + id UUID PK, organization_id UUID NOT NULL, user_email TEXT, agent TEXT, + module_slug TEXT, -- which module drove the spend → per-module margin + model TEXT, tier TEXT, + prompt_tokens INT, completion_tokens INT, cached_tokens INT, + provider_cost_usd NUMERIC(14,8), -- what it cost YOU + billed_credits NUMERIC(14,4), -- what you charge THEM + request_id TEXT UNIQUE NOT NULL, -- ← idempotency; retries must not double-bill + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` +`request_id UNIQUE` is not decoration. Retries, reconnects and the streaming rebuild path +all create double-write opportunities, and a customer billed twice for one call is a +credibility event. + +**(4) A rate card — and sell credits, not tokens.** + +```sql +model_rate_card(model TEXT, input_credits_per_1k NUMERIC, output_credits_per_1k NUMERIC, + cached_input_credits_per_1k NUMERIC, effective_from TIMESTAMPTZ, + PRIMARY KEY (model, effective_from)); +``` + +**This is the most important commercial decision in §3.** Do not bill customers in raw +provider tokens: + +- Tokens are **provider-specific and model-specific**. Bill in tokens and you have + promised a price on DeepSeek that you cannot honour on Anthropic. +- **Providers reprice under you.** With a rate card, your margin is a table edit; without + one, it is a code change and a customer conversation. +- **You can price cache hits lower.** `prompt_cache.py` already ships. "Cached context is + billed at 25%" is a real, differentiated selling point — and it costs you almost nothing + because it reflects your actual cost. +- Customers **cannot reason about tokens** but can reason about "10,000 credits ≈ a month + of normal email triage." + +This is what OpenRouter, Cursor and Vercel's AI Gateway all do, and for these reasons. + +### 3.3 Failure semantics — decide now, not at 2 a.m. + +**Soft-block with a grace overdraft.** At zero balance, LLM calls return a specific 402 +that the UI renders as "out of credits — top up", the **non-AI parts of every module keep +working**, and a ~10% overdraft prevents a hard stop mid-sentence. Auto-top-up is the +default for paid plans; alert at 80%. + +A hard cut-off mid-workflow generates a support ticket and a refund request that together +cost more than the overdraft. This is a business decision encoded in a config value — +write it down. + +### 3.4 BYOK is a tier, not an exception + +Some customers will insist on their own Anthropic/OpenAI key (data policy, existing +committed spend). Support it: `provider_keys` becomes `(organization_id, provider)` (§6), +and a BYOK org is **metered but not charged for tokens** — you charge the platform fee +only. This also caps *your* financial exposure on your largest accounts, which is why +nearly everyone in this space offers both. + +### 3.5 "Will an LLM be able to do that?" + +**No LLM is involved, and none should be.** Metering is deterministic bookkeeping: count +tokens, multiply by a rate, decrement a balance, write a row. The only judgement call is +the rate card, and that is a business decision made once by a human. Never let a model +decide what to bill. + +--- + +## 4. DECISION — billing architecture + +> ### `Your database is the source of truth for entitlements and usage.` +> ### `The payment processor is the source of truth for money.` +> Never call the processor on the request path. Never recompute an invoice it has issued. + +### 4.1 Components + +**(a) The Operator Console — build this early.** A separate surface (`/operator`) that +**only your staff** can reach, never bundled into the tenant UI, showing per company: +plan and MRR · seats purchased vs **assigned** per module · credit balance and burn rate · +last invoice status · trial expiry · activity (last login, 7/30-day actives). + +This answers the owner's question directly — *"depending on what modules, how many users +are using in that particular company"* — and it is simultaneously your revenue instrument, +your churn radar and your support tool. Unassigned seats and unowned-but-viewed modules +are your upsell queue. + +**(b) Billing tables** (control-plane DB, alongside §2's): +```sql +org_subscription(organization_id PK, provider, provider_customer_id, + provider_subscription_id, plan, status, trial_ends_at, + current_period_start, current_period_end); + +credit_ledger(id, organization_id, delta NUMERIC, reason, ref, balance_after, + created_at); -- APPEND-ONLY. Balance is SUM(delta), cached in Redis. + -- Never UPDATE a balance column: you lose the audit + -- trail exactly when a customer disputes a charge. + +usage_rollup(organization_id, period DATE, dimension, quantity, + PRIMARY KEY (organization_id, period, dimension)); + -- nightly from usage_event; raw kept ~90d, rollups forever + +invoice(id, organization_id, provider, provider_invoice_id, period, + amount, currency, status, hosted_url); -- mirror, so the customer sees + -- invoices without a provider round-trip +``` + +**(c) The reconciliation loop — the part that always bites.** Webhooks get lost, cards +fail, admins downgrade mid-cycle. A nightly job compares your `org_module_entitlement` and +seat counts against the processor's subscription items and **alerts on drift**. This repo +already has a `reconciler` service — same pattern, new subject. + +**(d) Lifecycle state machine**, written once, read by every module via +`entitlement.state`: +``` +trial → active → past_due (grace: warnings, still working) + → suspended (login works · modules locked · DATA RETAINED) + → cancelled (export window) → deleted +``` +> **Never delete customer data on non-payment without an export window.** It is a trust +> matter, a DPDP/GDPR matter, and the difference between a churned customer who might come +> back and one who tells people not to buy from you. + +### 4.2 How the seat charge is actually computed + +Per module: `quantity = COUNT(*) FROM user_module_seat WHERE module_slug = ?`, pushed to +the processor as the subscription item quantity on assignment/unassignment, with +proration. For mid-cycle changes, charge on **peak assigned seats in the period** or the +processor's standard prorated behaviour — **pick one and state it in the contract.** +Ambiguity here is the single most common source of B2B billing disputes. + +### 4.3 Payment processor — and the India question + +Stripe supports this shape natively: **Billing Meters + meter events** for usage, +subscription items for seats. Note the current API reality: the legacy usage-records API +was removed in API version `2025-03-31.basil`, so **every metered price now requires a +backing Meter**, and the v2 Meter Event Stream handles high-volume ingestion (~10k +events/sec) if you ever meter per-call rather than per-rollup. + +Two models, and the recommendation is to run both: + +| Model | Mechanism | Use for | +|---|---|---| +| **Prepaid credits** *(default)* | Customer buys a credit pack; you decrement the ledger. Processor sells a one-off/top-up product. | **Recommended default.** No bill shock, no collections risk, best fit for SMB and for India. | +| **Postpaid metered** | Report meter events; processor invoices at cycle end with graduated tiers. | Enterprise on invoice terms. | + +> ⚠️ **India-specific, and it matters because you are billing from India.** For domestic +> INR recurring collection, RBI's e-mandate rules make recurring card auto-debit +> genuinely painful above the additional-factor threshold, and Stripe's India coverage is +> narrower than its international coverage. **Razorpay/Cashfree** handle UPI Autopay and +> e-NACH properly. **Recommendation: a `payment_provider` seam** — Stripe for +> international, Razorpay for India — with **both writing the same +> `org_subscription` / `org_module_entitlement` / `credit_ledger` tables.** +> +> **Do not let the processor's data model become your data model.** Entitlements are yours; +> the processor is a device for collecting money. That indirection is also what makes +> prepaid credits and manual/enterprise invoicing work without a second code path. + +**Accounting.** Invoices, GST/VAT/sales tax and dunning belong to the processor (Stripe +Tax or the Razorpay equivalent), not to your app. Export to books (Zoho Books is the +natural choice — you already integrate Zoho CRM) **nightly, not per transaction**. +Deferred-revenue recognition on annual prepay lives in the accounting system, never in +CommandCenter. + +--- + +## 5. Phasing — what to build, in order + +Each phase is independently shippable and each one is sellable before the next exists. + +| Phase | Work | Est. | Gate | +|---|---|---|---| +| **0 — Blockers** | §6: per-run credential scoping ✅ · per-org provider keys ✅ · self-mutation containment ✅ · **no-raw-SQL agent tools ✅ (MT-0c-1)**. ⚠️ **MT-0c-2 (the container tier) is deliberately NOT here** — D16 moves it to a precondition of the §5.1 pooled cutover, because with one tenant per box an escaped agent reaches only its own data | 1–2 wk · **DONE** | **Nothing ships to a second customer before this** | +| **1 — Tenancy** | org_id + FORCE RLS on all tables (generated), **org_id in every PK and index prefix** (§1.8a), tenant binding at **all eight connection paths** (§0.1), `acb_app` role, `create_engine` + `psycopg.connect` ratchets, Mem0 decision, **no raw-SQL tool for agents** (§1.8a), Redis prefixing, subdomain resolution, identity/membership split (+ the external-IdP call, §1.8a), per-tenant logical backup job, partitioning for the heavy tables, build-failing coverage test | 4–5 wk | The big one. §0.1, §1.3, §1.6, §1.8a, §1.9 | +| **2 — Entitlements** | module catalog, entitlement + seat tables, `intersect()` mask, 402 vs 403, `ModuleGate` + upsell, non-HTTP gating, **per-org feature flags + release channel** (§1.4b — same lookup shape as the entitlement mask) | 2–3 wk | **Sell here.** Invoice by hand while proving the model. | +| **3 — AI credits** | per-org virtual keys, Redis budget gate, rate card, `usage_event`, credit ledger, top-up | 2–3 wk | §3 | +| **4 — Billing automation** | Stripe + Razorpay seam, webhooks → entitlements, dunning, Operator Console, reconciler | 3–4 wk | §4 | +| **5 — Tiers & compliance** | Dedicated-DB tier, **per-tenant envelope encryption for sensitive columns** (§1.1a — pull into Phase 1 if those columns are being touched anyway), residency, SOC 2 groundwork, DPA/DPDP | ongoing | Sell before you build this | + +**Do not reorder 1 before 0, or 3 before 1** — metering without tenant resolution meters +nothing, and entitlements over unisolated data are a UI convention rather than a control. + +**You can sell during Phase 2.** Manual invoicing for the first ten customers is normal +and is how you learn whether the module split and the price points are right, before +automating them. + +### 5.1 Start siloed, cut over at the crossover *(owner input 2026-08-08: 10–50 seats)* + +The phases above describe the destination. They do **not** require waiting 4–5 weeks +before the first customer, and at 10–50 seats per company they should not. + +> **Customers 1–5: run them as silos. Build Phase 1 in parallel. Cut over at 8–12.** + +**Why this is right rather than a compromise.** §1.4's crossover is ~8–12 customers, so +below it silo is genuinely cheaper *and* reaches revenue sooner. Five hand-run +deployments teach you which modules customers actually buy and what they pay — the two +inputs §8 says are still open — and that learning is worth more than a month of +architecture built against guesses. + +**The four conditions that make it a bridge rather than a trap.** Without these it is +not a staged rollout, it is silo-by-default arrived at by drift: + +1. **Phase 0 is non-negotiable even for silos.** Process-global credentials (§6.1) leak + *between concurrent runs* — the second tenant needn't be on the same database for + that to matter, only in the same process. Self-mutation containment (§6.2) likewise. +2. **Every silo runs the pooled schema**, with `organization_id` populated and RLS + enabled from day one, even though the database holds one tenant. A silo is then a + pooled deployment with N=1, and cutover is a data move rather than a migration. + **Skipping this is what turns the bridge into a rewrite.** +3. **One deploy pipeline, parameterised by target** — never a per-customer script. The + moment two boxes deploy differently, §1.4's version-skew defect has arrived. +4. **A written cutover trigger**, checked monthly: customer count ≥ 8, *or* deploy + overhead exceeding roughly a day a month, *or* the first version-skew incident — + whichever comes first. **A bridge with no trigger is a destination.** + **ADOPTED 2026-08-09** (recorded on the board — `work_plan.md` §2 WS-29 row — per + §11.2 item 4; the monthly check is the owner's). + +--- + +## 6. Blockers — fix before a second tenant exists + +These are not features. Each is a live cross-tenant defect the moment a second company's +data is on the box, and each is cheap now and expensive later. + +### 6.1 Process-global credential injection ⚠️ **HARD BLOCKER** + +`orchestrator/executor.py:4335-4411` writes every run's resolved integration credentials +into `os.environ` (`:4388`) and restores afterwards (`:4409`). **The code already documents +the flaw** at `:4364`: *"`os.environ` is process-global, so under concurrent [runs]…"*. + +Under one tenant this is a within-org concern that `tenancy_and_visibility.md` §1.1 +correctly deferred. **Under two tenants it is a credential leak**: tenant A's Zoho/Gmail +token is readable by tenant B's concurrently-executing agent — and agents run +model-generated tool calls, which is precisely the code you must assume is hostile. + +**Fix:** pass credentials through the run context / a per-run scoped environment +(subprocess env, contextvar-backed resolver), never the process environment. This is +BO-7-adjacent and is the prerequisite for every other item here. + +### 6.2 Self-mutation writes to the shared monorepo ⚠️ **HARD BLOCKER** + +Root `AGENTS.md` non-negotiable #3 already flags this: native-MAF agents land approved +self-mutations by opening a PR **against this monorepo**, and the constraint says it +*"MUST be swapped for a tenant-isolated mechanism before any multi-tenant/customer +deployment — third parties must never push to the shared monorepo."* +See `docs/DESIGN_LIMITATION_native_maf_mutation.md`. + +**Fix, in ascending order of effort:** (a) disable self-mutation for non-first-party +tenants — a config gate, days; (b) per-tenant agent repositories; (c) a mutation sandbox +whose output is a tenant-scoped artifact, never a push. **(a) is sufficient to unblock +Phase 1 and should be taken first.** + +### 6.3 Deployment-singleton credentials + +`provider_keys` is `provider TEXT PRIMARY KEY` (`08_provider_keys.sql:6-7`); +`mcp_servers`, `plugins` and `model_config` have no owner or org column. All must become +tenant-keyed. `tenancy_and_visibility.md` §1.1 called deployment-wide credentials *"exactly +the right shape"* — **true under its §1 decision, false under this one.** + +### 6.4 The `org` subject means "every active user on the box" + +`packages/acb_auth/acb_auth/access.py:400-402`: `_ORG_MEMBER_SQL` is `SELECT email FROM +app_user WHERE status = 'active'` — **no org filter**. Under pooled tenancy the `org` +subject would expand across every customer. Leak sites 1–10 in +`tenancy_and_visibility.md` §1.1 were *"moot by definition"* under deployment-per-tenant; +**this decision un-moots all of them.** RLS makes most of them correct automatically (the +row set is already tenant-constrained) — but each must be *verified*, not assumed, and +site 9 (`_HAS_OWNER_SQL`, `:522`, with no org filter, which makes +`ensure_owner_bootstrap()` a permanent no-op once any owner exists anywhere) is a +**lockout that RLS does not fix** and must be repaired by hand. + +> ⚠️ **Re-derive these anchors before editing.** `tenancy_and_visibility.md` §1.1 +> publishes `access.py:338-340` and `:460-464` for these two constants; measured +> 2026-08-08 they are at `:400` and `:522`. That document has already been through two +> anchor-correction passes for the same reason — use the §9 commands, not the numbers any +> document quotes. + +### 6.5 TV-1 still applies, and now leaks for real + +The three `org_group` slug-only joins (`tenancy_and_visibility.md` §2 / board **WS-14a**) +are cross-organization matches by construction. That document rated them "wrong within one +org too, nothing leaks today." **Under this decision they leak.** WS-14a's priority rises +from cleanup to prerequisite. + +--- + +## 7. Explicitly rejected + +Recorded so they are not re-proposed, and so the reasoning survives: + +1. **Container/database per customer as the default tier.** §1.4. Kept as a priced + enterprise tier only. +1b. **One container per organization on the same VPS.** §1.7 — it fragments the memory + that pooling exists to share, delivers no real isolation on shared hardware, keeps + every operational cost of database-per-tenant, and makes one box's failure everyone's. + Dedicated containers only buy something on dedicated hardware. +1c. **Schema-per-tenant.** §1.8 — the strongest rejected alternative, and rejected on one + property: **RLS fails closed (zero rows), `search_path` fails open (another tenant's + rows, silently).** Re-take it if the market turns out to be a few dozen large accounts. +2. **`X-Organization-Id` as the tenant source** (proposed in + `multi_user_organization_research.md` §17.3). Client-settable tenancy is a one-line + cross-tenant read. The tenant comes from the authenticated session or a tenant-scoped + API key. §1.5. +3. **A separate LLM proxy process.** §3.1 — the gateway `/v1` already is one. +4. **Billing customers in provider tokens.** §3.2(4) — sell credits. +5. **Billing on active users.** §2.2 — sell assigned seats; put the variability in AI + credits. +6. **Entitlements expressed as roles/permissions.** §2.1 — the customer's admin owns roles; + you own entitlements. +7. **Per-query `WHERE organization_id = ?` as the isolation mechanism.** RLS at the + connection seam. A predicate you must remember is a predicate someone will forget across + 209 files; a database policy is not forgettable. Hand-written predicates are permitted + as an *optimisation* (index selectivity), never as the control. +8. **A second scoping doctrine.** `tenancy_and_visibility.md` §3.2's standing rule is + unchanged and extends here: tenant isolation is `organization_id` + RLS, visibility + inside a tenant is `email | group: | org`. Two mechanisms, two axes, no third. + +--- + +## 8. Open — owner decisions still needed + +1. ~~**Price points and module boundaries.**~~ **ANSWERED 2026-08-09 (owner, D18): + Core ₹600 per user per month** (Tasks, Calendar, Chat, People directory) **+ ₹300 + per user per month per add-on module** (CRM, Projects, Email, Meetings, WhatsApp, + Workflows). Selected from agent-drafted options anchored on Zoho India pricing; the + owner expects to revise against the first five silo customers (§11.2 item 3 said + drafting-now-revising-later is correct). §2.4's module split becomes the SKU list's + starting shape — MT-2's entitlement catalog seeds from this. +2. ~~**Credit-to-rupee conversion and target gross margin on AI.**~~ **ANSWERED + 2026-08-09 (owner, D18): the credit unit is a ₹10 "AI action" at ~50% gross margin** + — the `model_rate_card` prices each model call at provider cost × 2, denominated in + credits, so buyers see actions, never tokens (§3's rate-card rule unchanged: sell + credits via the rate card, never provider tokens). Per-action costing lands with + MT-3's rate-card build. +3. **Payment provider split.** Razorpay-for-India + Stripe-for-international is the + recommendation (§4.3); a single provider is simpler and worth considering if the initial + market is one geography. +4. **Data residency commitments.** Whether to promise India-only data at launch. This is + cheap to promise now (one region) and expensive to add later. +5. ~~**Whether first customers get the pooled tier or hand-run silos.**~~ + **ANSWERED 2026-08-08** by the owner's seat-count input (10–50 users per customer): + **silo customers 1–5, build Phase 1 in parallel, cut over at 8–12.** The reasoning, + the crossover arithmetic and the four conditions that keep it a bridge rather than a + drift are in **§5.1**. + +--- + +## 9. Verification + +```bash +# §0 — one engine, one session seam +grep -n "create_async_engine\|def get_db\|async_sessionmaker" packages/acb_common/acb_common/db.py + +# §0 — the intersect() seam entitlements will reuse +grep -n "def intersect" -A 12 packages/acb_auth/acb_auth/permissions.py + +# §0/§3 — the LLM choke point and the existing per-call cost computation +grep -n "def _emit_usage" -A 30 packages/acb_llm/acb_llm/client.py +grep -n "_emit_usage\|require_llm_api_auth" apps/services/gateway/gateway/routes/v1_compat.py + +# §0/§6.3 — deployment-singleton credentials +grep -n "PRIMARY KEY" infra/postgres/08_provider_keys.sql + +# §6.1 — process-global credential injection, and its own admission +sed -n '4335,4415p' apps/services/orchestrator/orchestrator/executor.py + +# §6.4 — the org subject with no org filter +grep -n "_ORG_MEMBER_SQL\|_HAS_OWNER_SQL" -A 6 packages/acb_auth/acb_auth/access.py + +# §1 — the retrofit surface +grep -rn "organization_id" infra/postgres/*.sql | grep -ci "add column\|organization_id UUID" +ls infra/postgres/[0-9]*_*.sql | wc -l +``` + +--- + +## 11. THE WORK PLAN — dispatchable tickets + +**Board rows:** `work_plan.md` §2 *Multi-tenancy* → **WS-29**. Ticket IDs are `MT-n` +(R2: no phase-ID reuse). This section owns *what to build and how*; the board owns +*order and ownership*. + +**Honesty about dispatchability.** MT-0 and MT-1 carry the full seven-point contract +(`work_plan.md` §1) and are dispatchable today. **MT-2 through MT-5 are scoped, not +dispatchable** — each names what must be answered to make it so. Writing testable +acceptance for Phase 4 today would be inventing it, and §1's contract point 3 forbids +"done when: owner call" dressed up as a criterion. + +**Standing rules for every ticket below.** R1 — migration numbers are *"next free at build +time"*, never written here. Anchors are re-verified at dispatch, never trusted from +authoring time (§0.1 exists because that rule was broken). R4 — a PR that ships a ticket +updates this spec's status header in the same PR. + +--- + +### MT-0 — Blockers · *nothing ships to a second tenant until all four are in* + +> These are not features and they do not gate on the tenancy decision. **MT-0a and MT-0c +> are live defects the day two companies share a process — not a database, a *process*.** + +#### MT-0a · Per-run credential scoping — kill process-global `os.environ` · ✅ **BUILT 2026-08-08, pending review** +**Owner:** §6.1 · **Anchor:** `orchestrator/executor.py:4335-4411` (write `:4388`, restore +`:4409`; the flaw is documented in-code at `:4364`) + +**Done when:** +1. A run's resolved integration credentials reach the agent through the **run context or a + per-run scoped subprocess environment** — never `os.environ` of the gateway process. +2. A hermetic test proves two concurrent runs with different credential sets **cannot + observe each other's values.** Verified **red** against today's code first, and the + failure quoted in the PR. +3. `os.environ` is not written by `executor.py` for integration credentials at all — a + grep assertion in the test, so a later PR cannot reintroduce it. +4. `uv run ruff check` clean on the touched files only (never `ruff check .` — ~1983 + pre-existing errors on this tree, not a signal). + +**Verify:** `uv run pytest tests/unit/test_integration_env_scoping.py -v -rs` (12 passed) +· `uv run pytest tests/unit/test_code_tools.py tests/unit/test_web_tools_fallback.py +tests/unit/test_acb_skills.py tests/unit/test_agent_paths.py -q` (72 passed across the fence) + +**As built.** The bridge is a **`ContextVar` in `acb_skills.integrations`** +(`bind_run_credentials` / `release_run_credentials` / `run_credentials` / +`credential`), not a scoped `os.environ` write. Consumers: `code_tools._script_env` +for subprocess scripts, and `integrations.credential()` for the **three in-process +reader lines** that existed — `skill-clickup-sync/core.py` ×2 and `web_tools.py` ×1. +The ~20 `os.getenv` calls in `integrations.py` itself are **resolvers reading the +operator's `.env`** and correctly still do. + +**Verified red first**, and quoted: replaying the interleaving against the previous +implementation printed `run B saw run A's : clk-secret-123` → `FAIL (RED) — +AssertionError: run B could read run A's credential`. *(Only one direction +reproduced; the other was masked because run B's teardown completed before run A +resumed — itself a demonstration of how timing-dependent the old scoping was.)* + +⚠️ **Two limits, deliberately not closed by this ticket.** (1) An **operator-provided** +env value still wins and is still process-global — that is unchanged precedence, and +making the operator's own store per-tenant is **MT-0d**. (2) The declared-*list* +(`_WRITE_ARTIFACT_CONTEXT`) is still a process-global dict despite a docstring calling +itself coroutine-local; a concurrent run can still widen *which names* are looked up, +but a widened name now yields nothing unless this run also holds that credential. + +#### MT-0b · Self-mutation containment for non-first-party tenants · ✅ **BUILT 2026-08-08, pending review** +**Owner:** §6.2 · root `AGENTS.md` non-negotiable 3 · `docs/DESIGN_LIMITATION_native_maf_mutation.md` + +**Done when:** a config gate disables native-MAF self-mutation for any tenant not flagged +first-party; it **defaults to disabled**; a test asserts a non-first-party tenant's failure +event produces no PR attempt. ⚠️ **`work_plan.md` WS-3 records that no `first_party` field +exists on any manifest, config or column** — this ticket creates it. Do not assume it. + +**As built.** Migration **157** adds `organization.first_party BOOLEAN NOT NULL DEFAULT +false`, backfilling `slug='default'` to true — so today's behaviour is unchanged and every +organization created afterwards is contained *by construction*. +`mutation._self_mutation_permitted()` is called **first** in `attempt_self_mutation`, +before the attempt tally, the sandbox, git or the network, and returns +`MutationResult(attempted=False, skipped_reason=…)`. + +**It fails closed on every path** — unreachable DB, missing column, and (the multi-tenant +case) no *sole* organization, because the untenanted query requires `count(*) = 1` rather +than falling back to the default org. A default-org fallback would keep answering `true` +after tenant #2 arrived, which is the leak. `SELF_MUTATION_DISABLED=1` is an operator +hard-off that short-circuits before any query. + +**Verify:** `uv run pytest tests/unit/test_mt0b_self_mutation_containment.py -v -rs` +(8 passed) — verified **red** first: 6 of the 8 failed against the pre-gate source. +One test deliberately guards the *other* direction — that a first-party org still reaches +the tally — because "refuse everything" would pass every other assertion here while +silently switching the feature off for Fracktal too. + +#### MT-0c · The execution-plane sandbox — **SPLIT 2026-08-08 (D16)** + +> **`DECISION (agent-proposed, owner may overrule)` — 2026-08-08.** The owner delegated +> this call. Recorded under the same label as D13 so it stays overrulable. +> +> **MT-0c as one ticket was the wrong shape.** Its four clauses have wildly different +> costs and wildly different *urgency*, and bundling them meant the cheapest, most +> valuable one waited behind the most expensive one. + +**MT-0c-1 · No agent tool accepts SQL · ✅ BUILT 2026-08-08, pending review** + +**Why now, not at cutover.** §0.9.3 already names this a *condition on the pooled +decision*. It was **already violated** — and not only in a multi-tenant sense. +`query_history` took a **model-generated SQL string** and executed it through +`acb_graph.get_session()` (connection path 4 in §0.1 — the sync `create_engine` the seam +ratchet never inspected). It was registered in `agent-orchestrator/config.json`, injected +at `_tool_injection.py:623`, and advertised to the model as *"Run a SELECT-only SQL +query"*. + +**Its guard was wrong in both directions, measured 2026-08-08:** + +| | Result | +|---|---| +| `SELECT role, content, created_at FROM chat_message` — *the tool's own documented example* | **Rejected** — `CREATED_AT` contains the substring `CREATE` | +| `SELECT * FROM provider_keys` | **Allowed.** So were `email_messages`, `app_user`, everything. The guard policed *verbs*; nothing policed *tables* | + +So this was a live within-org read primitive **today**, and a cross-tenant one the day +MT-1 lands. **As built:** `query_history` now takes search criteria — the model supplies +*values*, never syntax; the SQL is a fixed string with bound parameters over exactly the +two tables it always documented. Results narrow to the acting member's own sessions when +the run context names one (the old tool could read any member's conversations — its own +docstring example did). A **build-failing ratchet** (`test_no_agent_tool_accepts_a_sql_parameter`) +stops the shape returning. + +**Verify:** `uv run pytest tests/unit/test_mt0c1_no_raw_sql_agent_tools.py -v -rs` +(9 passed; verified **red** — 6 of 9 failed against the SQL version). ⚠️ The pinned call +contract in `tests/unit/test_tool_schema_diet.py` was updated **deliberately** — that +ratchet exists to stop a contract changing by accident, and it caught this correctly. + +**MT-0c-2 · The container/microVM tier (WS-3 T2) · 🔴 STAYS OWNER-GATE, STAYS PARKED** + +**Why parked is still right, and this is the substance of the decision.** D10 parked T2 +because *"the ladder must hold against trusted colleagues, not hostile users."* That +reasoning **still holds for the silo phase** (§5.1): with one tenant per box, an escaped +agent reaches only its own tenant's data, which is the blast radius it already had. + +T2 becomes load-bearing at the **pooled cutover** (customer 8–12), not at customer #1. +Building Firecracker-grade isolation before the first customer exists is speculative +infrastructure — weeks of work whose value arrives months later, paid for out of the +runway that should be buying customers. + +> **The trigger:** MT-0c-2 must land **before** the first pooled tenant, i.e. it is a +> precondition of the §5.1 cutover, not of Phase 0. Un-parking is still **OWNER-GATE** +> and `work_plan.md` §6 keeps its entry. + +**What this split does NOT do.** It does not weaken §0.9.3. The *condition* on the pooled +decision was "no raw-SQL tool **and** no agent-reachable path can set `app.tenant_id`" — +MT-0c-1 satisfies the first half now, and the second half cannot be violated before +`app.tenant_id` exists (MT-1b). The container tier is defence in depth on top of both, +which is why it can wait; the two conditions themselves cannot. + +#### MT-0d · Per-organization provider keys · ✅ **BUILT 2026-08-08, pending review** +**Owner:** §6.3 · **Anchor:** `08_provider_keys.sql:6-7` (`provider TEXT PRIMARY KEY`) + +**Done when:** `provider_keys` is keyed `(organization_id, provider)`; `mcp_servers`, +`plugins` and `model_config` carry an org column; `acb_llm/key_store.py` and +`model_config.py` resolve by tenant; the single existing org backfills; a test asserts a +lookup without a tenant returns nothing rather than another tenant's key. + +**As built.** Migration **158** re-keys all four: `provider_keys` → +`PRIMARY KEY (organization_id, provider)`, `model_config` → `(organization_id, key)`, +`mcp_servers` → `(organization_id, name)`, and `plugins` keeps its UUID pk while its +deployment-wide `name UNIQUE` becomes `(organization_id, name)`. Every existing row +backfills to the operator's org, and a `DO` block **refuses to re-key** if any row is left +ownerless rather than proceeding. + +**The untenanted resolution is the design decision worth reviewing.** +`key_store._resolve_org(None)` resolves to *the sole organization* — literally +`WHERE (SELECT count(*) FROM organization) = 1`. So the ~20 existing call sites keep +working unchanged today, and **every one of them fails closed the moment a second +organization exists**, which is exactly when MT-1 must supply a real tenant. A +"default org" fallback would keep answering after tenant #2 and serve the operator's keys +to a customer. Reads return `""`; **writes raise**, because a credential written with no +owner is how a key ends up readable by the wrong tenant. + +⚠️ **The in-memory cache was the other half, and correct SQL does not protect it.** +`ProviderKeyStore._cache` was keyed by `provider` alone — the second tenant asking for +`openai` would have been served the first tenant's **decrypted** key straight from memory, +with no query issued at all. It is now keyed `(organization_id, provider)`, pinned by its +own test. + +**Verify:** `uv run pytest tests/unit/test_mt0d_per_org_credentials.py -v -rs` (8 passed) +— verified **red** first: 7 of 8 failed against the pre-fix source. + +⚠️ **Not verified against a live database.** No Docker daemon was available in the build +environment, so migrations 157 and 158 were **statically** checked only: all four +auto-generated constraint names confirmed against `schema.generated.sql`, and no foreign +key anywhere references the primary keys being re-pointed (so the drop-and-re-add is +safe). **Run both against a scratch Postgres before deploying** — `apply_migrations.sh` +replays from `02_` upward, and a failure there fails the deploy. + +--- + +### MT-1 — Tenancy foundation · *the big one · 4–5 weeks* + +#### MT-1a · Control plane, identity split, placement · ◐ **PARTIAL 2026-08-08** + +> **Built:** migration 159 (`tenant_placement`, `user_identity`, `org_membership`, seeded from +> `app_user`) + `acb_common/placement.py`. **Additive and inert** — `app_user` is untouched and +> still authoritative. +> **NOT built — MT-1a-2:** cutting the auth path over. `acb_auth/access.py` carries two +> `ON CONFLICT (email)` upserts on the live sign-in path (`:205`, `:509`), and a half-migrated +> identity is worse than an unmigrated one. ⚠️ The spec cited those in `members.py`; measured, +> they are not there — another stale anchor. +**Owner:** §1.5, §0.9.5 + +**Done when:** +1. A **separate control-plane database** (or at minimum a separate schema with its own + role) holds `organization`, `tenant_placement`, and the billing/entitlement/usage + tables. It carries **no** tenant business data and is **not** under RLS. +2. `user_identity(id, email UNIQUE, …)` + `org_membership(user_id, org_id, status, …)` + replace `app_user`'s dual role. ⚠️ **`app_user.email` global uniqueness is depended on + by two `ON CONFLICT (email)` upserts — measured 2026-08-08 at `acb_auth/access.py:205` + and `:509`, NOT the `members.py:173`/`access.py:447` pair this line first published + (anchor corrected 2026-08-09; re-derive with grep at build time)** — both are rewritten + in this ticket, and a test pins that the same email can hold membership in two orgs. +3. `tenant_placement(organization_id, target, region)` exists and is consulted, **even + though every row resolves to the same target on day one.** A test asserts the resolver + reads it rather than a constant. + +#### MT-1b · `organization_id` + FORCE RLS on every table · ◐ **GENERATED, NOT APPLIED** + +> **Built:** `scripts/gen_tenant_migration.py` + `tests/unit/test_tenant_coverage.py`. +> 146 tables discovered, **135 tenant-scoped**, 11 exempt-with-a-reason. +> ⚠️ **Output goes to `infra/postgres/generated/` — OUTSIDE the sequence the deploy replays**, +> in four separately-appliable phases. `apply_migrations.sh` carries a lock-timeout design +> written after a **14h44m outage** of exactly this shape, and there is no database in the +> build environment to try any of it against. Promoting these is a human act in a window. +> **Phase 4 requires MT-1c deployed and verified first**, or every unbound connection reads +> zero rows and the product goes dark. +**Owner:** §1.3 · **the generated migration, not 143 hand-written ones** + +**Done when:** +1. Every application table carries `organization_id UUID NOT NULL DEFAULT + current_setting('app.tenant_id', true)::uuid` with an FK, an index, **and** + `ENABLE` + `FORCE ROW LEVEL SECURITY` with a `tenant_isolation` policy. +2. **`organization_id` is in every primary key and every index prefix** (§1.8a — the + distribution-key discipline; retrofitting it later means rewriting every PK). +3. The gateway connects as a **non-owner, non-superuser role** (`acb_app`). Migrations + still run as the owner. A test asserts the app role has neither `BYPASSRLS` nor + ownership. +4. **A build-failing coverage test** enumerates `pg_tables` and fails if any application + table lacks the column, FORCE RLS, or a policy — the same ratchet discipline as + `tests/unit/test_db_engine_seam.py`, so a table added tomorrow is covered. +5. **Zero `SELECT`/`INSERT` statements in the gateway are rewritten by this ticket.** If a + query needed changing, the column default or the policy is wrong. + +#### MT-1c · Tenant binding at all **ten** connection paths + two new ratchets · ◐ **SEAM + RATCHETS BUILT** + +> **Built:** `acb_common.db.tenant_session()` (SET LOCAL inside an explicit transaction, fails +> closed when unbound) · the `create_engine` ratchet · the new `psycopg.connect` ratchet. +> **NOT built:** the call-site conversion, and the Mem0 decision (§0.1 path 8). +> +> ⚠️ **The conversion surface is 561 sites across 138 files, not the "~200" this spec said +> until 2026-08-08.** The undercount happened because the dominant idiom is the *aliased* +> import — `from gateway.db import get_db as _get_db`, then `await _get_db()` (441 of the +> 561) — so a grep for `get_db()` alone misses four fifths of it. Measured: +> `grep -rhoE "await _?get_db\(\)" --include=*.py apps packages | wc -l`. **This makes the +> conversion, not the RLS migration, the long pole of MT-1** — see the handover runbook §2. +**Owner:** §0.1 — **read its table before starting; the inventory is the ticket** + +**Done when:** +1. All eight paths bind a tenant. **`SET LOCAL`, never `SET`** (§1.3) — a dedicated test + proves a pooled connection returned and re-borrowed carries **no** tenant setting. +2. `test_db_engine_seam.py` is extended to **`create_engine`** as well as + `create_async_engine` — path 4 (`acb_graph/db.py:32`) exists today precisely because + the ratchet only inspected the async name. +3. A **new ratchet for `psycopg.connect`**, allow-list-with-a-reason, covering paths 5–7. +4. **The Mem0 decision (path 8) is taken and written into this spec** — **taken + 2026-08-09: D17, Option A (conninfo options); the build must implement it or + escalate why not** — conninfo options, + a per-tenant role, or scope-string-only isolation. **Leaving it undecided fails this + ticket.** +5. A test asserts an **unbound** connection returns **zero rows** rather than another + tenant's (the fail-closed property §0.1 rests on). + +#### MT-1d · Background-job tenant binding · 🟢 AGENT-SAFE +**Owner:** §1.9 — *"a job that forgets doesn't leak one row; it leaks unbounded"* + +**Done when:** every scheduled/queued unit of work (ingestion scheduler, reconciler, +orchestrator runs, broker handlers, the Redis Streams consumer) carries an explicit +`organization_id` on its job record and binds it before any DB access; a test asserts a job +constructed without one **refuses to run** rather than defaulting. + +#### MT-1e · Redis: prefixes enforced by the client · ◐ **WRAPPER BUILT, CALL SITES NOT CONVERTED** + +> **Built:** `acb_common/tenant_redis.py` — a client that *cannot* express an unprefixed key, +> plus two AST ratchets (direct `redis` import; hand-written `cc:` literals). +> **NOT converted:** ~58 key sites across 10 clients. Deliberately separate — the docstring +> carries the migration path, including *not* writing a dual-read shim (every key is cache, +> presence or a bounded stream, so conversion is a cache-cold event, not a data migration). +> +> ⚠️ **Three things no ratchet can catch, all verified against the tree:** +> 1. `routes/chat.py:707` — `SCAN match="cc:active:*"` **enumerates every tenant's sessions** +> the moment a second exists. Highest severity in the inventory. +> 2. `ingestion/consumer.py:95` — `_GROUP = "cc-ingest"` is **one consumer group shared by all +> tenants**; §1.9 requires one per tenant. +> 3. **Untenanted non-`cc:` namespaces** invisible to the `cc:` ratchet: +> `ingestion:{clickup,zoho,gmail,dlq}`, `session_mem:`, `email:att:cache:` — plus +> `orchestrator/agents.py:436`, which hands `redis_url` to `agent_framework`'s +> `RedisHistoryProvider`, keying chat history **outside this wrapper entirely**. That one +> needs its own decision, not a conversion. +**Owner:** §0.9.4, §1.9 · **Anchor:** today's untenanted `cc:activity`, `cc:room`, +`cc:cost`, `cc:presence`, `cc:runactor`, … + +**Done when:** a wrapper client is the only way the codebase reaches Redis and it **cannot +construct an unprefixed key**; consumer groups are per-tenant; a grep-assertion test fails +the build on a direct `redis.asyncio` client outside the wrapper. *A convention is a thing +people forget; a client that cannot express the wrong thing is not.* + +#### MT-1f · Subdomain tenant resolution · 🟢 AGENT-SAFE +**Owner:** §1.5's binding rule + +**Done when:** the workbench resolves `.` and the tenant claim rides the +**authenticated session**; the gateway derives the tenant from the session or a +tenant-scoped API key **only**; a test asserts an `X-Organization-Id` header, query +parameter or body field is **ignored**, not honoured. *(This extends +`user_management_contract.md` rule 10 — that spec gains the eleventh rule in this PR.)* + +#### MT-1g · Blobs out of Postgres · 🟢 AGENT-SAFE +**Owner:** §1.6 · **Anchor:** `71_agent_blob_store.sql:30` (`content BYTEA`) + +**Done when:** `agent_blob` content lives in object storage keyed `/…`; the table +keeps metadata and a pointer; meeting media (already filesystem-backed at +`95_note_taker.sql:56`) moves to the same store. *Worth doing in any tenancy model — a +BYTEA column is the wrong home for file content in any topology.* + +#### MT-1h · Partitioning + per-tenant logical backup · 🟢 AGENT-SAFE +**Owner:** §1.6 + +**Done when:** the heavy tables (`email_messages`, the `*_embeddings` vector tables, +`chat_message`, `audit_event`) are partitioned on `organization_id` — **LIST for the +largest tenants, HASH/default for the tail** (one partition per tenant across all tenants +recreates the catalog pressure §1.8 rejects); **and** a per-tenant logical export/import +job exists and has been **run end-to-end at least once**, quoted in the PR. *"Restore this +one customer to yesterday" is the one capability database-per-tenant gives free, and it +costs one job here, not N databases.* + +#### MT-1i · The leak sites this decision un-mooted · ✅ **BUILT 2026-08-08** (one criterion open) + +> All five predicates derived, verified red first. ⚠️ `tenancy_and_visibility.md` §2 **done-when 3** +> (the DB-backed two-org behavioural fixture) is **NOT discharged** — it lives in files that skip +> entirely without Postgres. It needs a live database. +**Owner:** §6.4, §6.5 · absorbs board **WS-14a** + +**Done when:** the three `org_group` slug-only joins carry a derived org predicate +(`tenancy_and_visibility.md` §2's done-when 1–5 apply **verbatim** and are not restated +here — that spec owns them); `_ORG_MEMBER_SQL` (`access.py:400`) is org-filtered; and +`_HAS_OWNER_SQL` (`:522`) is org-filtered — **that last one is a lockout RLS does not fix** +and must be repaired by hand. + +--- + +### MT-2 … MT-5 — scoped, not yet dispatchable + +Each names the one thing that would make it so. **Do not hand these to an agent as written.** + +| Ticket | Scope | Owning § | To become dispatchable | +|---|---|---|---| +| **MT-2** Entitlements | `module_catalog` · `org_module_entitlement` · `user_module_seat` · the `intersect()` mask · 402-vs-403 · `ModuleGate` + upsell · non-HTTP gating · per-org feature flags + release channel | §2, §1.4b | ~~The SKU list and price points~~ **INPUT ANSWERED 2026-08-09 (D18: Core ₹600 + ₹300/module — §8 item 1).** Remaining to dispatch: write the seven-point ticket contract onto §2 (per-item done-whens + verification) — the input is no longer the blocker | +| **MT-3** AI credits | Per-org virtual keys · Redis budget gate + per-run circuit breaker · `usage_event` (idempotent on `request_id`) · `model_rate_card` · `credit_ledger` · BYOK tier | §3 | ~~The credit-to-rupee rate and target gross margin~~ **INPUT ANSWERED 2026-08-09 (D18: ₹10 AI-action unit, ~50% margin — §8 item 2).** Remaining to dispatch: the per-action cost model in the rate card + the seven-point contract onto §3 | +| **MT-4** Billing | `payment_provider` seam · Stripe + Razorpay · webhooks → entitlements · dunning state machine · Operator Console · reconciler | §4 | **The provider split decision** (§8 item 3) and MT-2 shipped | +| **MT-5** Tiers & compliance | Per-tenant envelope encryption · dedicated-DB tier activation · **drop Neo4j / graph into Postgres** · residency · SOC 2 groundwork | §1.1a, §0.9.4 | Nothing blocking. ⚠️ **Envelope encryption should be pulled into MT-1 if MT-0d or MT-1g touch those columns anyway** — retrofitting encryption onto populated columns is materially harder | + +--- + +### 11.1 Sequencing, and the one thing that is not sequential + +``` +MT-0a ──► MT-0c (owner-gate) MT-0b, MT-0d ─┐ + ├─► MT-1a ─► MT-1b ─► MT-1c ─► MT-1d + │ └─► MT-1e, MT-1f, MT-1g, MT-1h, MT-1i (parallel) + │ +Customers 1–5 shipped as silos (§5.1) ────────────────┘ ──► MT-2 ──► MT-3 ──► MT-4 ──► MT-5 +``` + +- **MT-1b before MT-1c.** Binding a tenant against tables with no policy proves nothing. +- **MT-1a before MT-1b.** The org rows the FK points at must exist first. +- **MT-1e–MT-1i are parallel** once MT-1c lands — five independent PRs. +- **MT-0 does not block selling.** §5.1's first five customers ship as silos *while* MT-1 + is built — but **MT-0a/b/d must be in before customer #2**, silo or not, because they + are process-level not database-level defects. + +### 11.2 Week one — what to actually do on Monday + +1. **Take the MT-0c decision** (un-park T2, or record why not). It is the only owner-gate + in MT-0 and everything in §0.9.3 waits behind it. *Owner, ~1 hour.* +2. **Dispatch MT-0a.** Largest live defect, self-contained, no dependencies. +3. ~~**Answer §8 items 1–2**~~ **DONE 2026-08-09 (D18)** — Core ₹600 + ₹300/module; + ₹10 AI-action credit at ~50% margin. Revision against the first silo customers is + expected and fine. +4. ~~**Write the cutover trigger down**~~ **DONE 2026-08-09** — adopted in §5.1 + condition 4 and carried on the board's WS-29 row. + +### 11.3 What this plan deliberately does not do + +- **No Kubernetes, no Citus, no service mesh, no microservice split** (§0.9.7). The + monolith is correct; what needs splitting is the three planes' trust boundaries. +- **No `organization_id` threaded into query bodies by hand** (§7 item 7). RLS is the + control; a hand-written predicate is an optimisation only. +- **No second scoping doctrine.** `tenancy_and_visibility.md` §3.2's standing rule is + unchanged: tenant isolation is `organization_id` + RLS; visibility *inside* a tenant + stays `email | group: | org`. +- **No acceptance written for MT-2…MT-5 until their named input exists.** A criterion an + implementer cannot test is worse than an empty ticket. + +--- + +## 10. References + +**Internal (binding):** `tenancy_and_visibility.md` (visibility ladder §3, project grants +§4, gap table §5 — all still current; §1 and §6 superseded here) · +`user_management_contract.md` (the ten rules; §1.5 adds an eleventh for tenant +resolution) · `org_access_control.md` (the shipped RBAC model) · +`multi_user_organization_research.md` §17 (prior research — §17.2's pooled-first +recommendation is adopted; §17.3's header-based tenant resolution is rejected) · +`department_centers.md` · root `AGENTS.md` non-negotiables 2, 3 and 10 · +`docs/DESIGN_LIMITATION_native_maf_mutation.md` + +**External:** [AWS — SaaS tenant isolation strategies: the bridge +model](https://docs.aws.amazon.com/whitepapers/latest/saas-tenant-isolation-strategies/the-bridge-model.html) · +[AWS Database Blog — multi-tenant data isolation with PostgreSQL row-level +security](https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security) · +[AWS SaaS multi-tenant architecture guide — pool/silo, onboarding, +metering](https://hidekazu-konishi.com/entry/aws_saas_multi_tenant_architecture_guide.html) · +[Multi-tenant SaaS architecture patterns +(2026)](https://architecturediagram.ai/blog/multi-tenant-architecture) · +[Stripe — analyze and query meter usage](https://docs.stripe.com/billing/subscriptions/usage-based/analytics) · +[Stripe — usage metering guide](https://stripe.com/resources/more/usage-metering) · +[Stripe — Langfuse: subscription + metered hybrid at billions of +events](https://stripe.com/customers/langfuse) · +[LiteLLM — multi-tenant architecture](https://docs.litellm.ai/docs/proxy/multi_tenant_architecture) · +[LiteLLM — virtual keys](https://docs.litellm.ai/docs/proxy/virtual_keys) · +[LiteLLM — budgets and rate limits](https://docs.litellm.ai/docs/proxy/users) · +[Revenera — SaaS licensing models](https://www.revenera.com/blog/software-monetization/saas-licensing-models-guide/) · +[Nalpeiron — SaaS licensing and entitlement management](https://docs.nalpeiron.com/education-and-training/licensing-education/learn-about-software-licensing-models/saas-licensing-and-entitlement-management) + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-29 — Multi-tenancy — turning CommandCenter into a product sold to other companies +**State cell (as of the move):** ✅ **Phase 0 DONE** (MT-0a/0b/0c-1/0d) · ◐ **MT-1 partial** (1a schema · 1b generated-not-applied · 1c seam · 1e wrapper · 1i done) · 🔴 MT-0c-2 OWNER-GATE (D16) · ◐ MT-2…MT-5 blocked on owner inputs +**Narrative (verbatim):** **Re-takes D11.** `tenancy_and_visibility.md` §1 set the tenant boundary at THE DEPLOYMENT and §6 put row-level tenancy, an org switcher and multi-org users out of scope. The business model changed — per module, per user, per month, plus metered AI — so §1/§6 are **superseded** by that spec's own re-take procedure. **§2–§5 of `tenancy_and_visibility.md` (the visibility ladder, the `group:` project grant, the gap table) are UNCHANGED and still binding**; tenancy is *which company*, visibility is *who inside it*. **The decision: tenant = `organization_id` enforced by Postgres RLS at the connection seam; the deployment is a placement, not a boundary.** Pooled standard tier, dedicated DB/stack as priced tiers. ⚠️ **The thesis is not a database thesis** (§0.9): agents execute model-generated tool calls over adversarial input, and the database can be defended by a policy that cannot be forgotten while the agent runtime cannot — so **the isolation budget belongs on the execution plane**, and MT-0c is the load-bearing ticket, not MT-1b. **Three findings that changed the plan:** (1) *"one engine, one `get_db()`"* was **wrong** — true of the request path, false of the process; §0.1 enumerates **eight** connection paths, two of which the seam ratchet never inspected (`acb_graph`'s sync `create_engine`; three raw `psycopg.connect` callers), which is why MT-1c also extends the ratchets. (2) **RLS fails closed** (unset `app.tenant_id` → NULL → zero rows) where `search_path` fails open — that property, not topology, is why schema-per-tenant was rejected (§1.8). (3) The customization layer that makes per-customer code forks unnecessary **already ships** — Custom Apps, Workflows (ADR-028), `dynamic_agents`, `pm_custom_fields`, `settings JSONB` (§1.4b). **Blockers before ANY second tenant, silo or pooled — process-level, not database-level:** MT-0a (integration credentials reach agents via process-global `os.environ`, `executor.py:4388`, flaw documented in-code at `:4364`) and MT-0b (self-mutation opens PRs against this monorepo — root `AGENTS.md` non-negotiable 3). **MT-0c is OWNER-GATE and inverts D10:** T2 is parked because *"the ladder must hold against trusted colleagues, not hostile users"* — selling externally replaces that threat model, so un-parking is the architecture, not optional hardening. **Rollout (§5.1): silo customers 1–5, build MT-1 in parallel, cut over at 8–12** — crossover is where silo's linear cost meets MT-1's one-time 4–5 weeks; every silo runs the pooled schema with `organization_id` + RLS from day one, or the bridge becomes a rewrite. **Absorbs WS-14a** as MT-1i: the three `org_group` slug-only joins were "wrong within one org, leaking in none" under D11 — **under D15 they leak**, and `_HAS_OWNER_SQL` (`access.py:522`, no org filter) is a **lockout RLS does not fix**. §11.2 is the week-one list. + +**Corrections applied 2026-08-09:** H1 is now SCRATCH-VERIFIED (157/158/159 applied + +idempotent on a full-ladder replica; baseline 213 passed / 2 skipped; prod apply = the +owner's merge of PR #404 — see the handover's H1 result block). MT-2/MT-3's owner +inputs are ANSWERED (D18 — §8 items 1–2). The §5.1 cutover trigger is ADOPTED. The +Mem0 path-8 decision is taken (D17, Option A). "MT-2…MT-5 blocked on owner inputs" is +therefore stale for 2 of 4: MT-2/MT-3 now lack only their seven-point ticket +contracts; MT-4 still needs §8 item 3 (payment-provider split). diff --git a/ai-company-brain/specs/saas_multitenancy_handover.md b/ai-company-brain/specs/saas_multitenancy_handover.md new file mode 100644 index 000000000..96ca1322a --- /dev/null +++ b/ai-company-brain/specs/saas_multitenancy_handover.md @@ -0,0 +1,442 @@ +# Multi-tenancy handover — execution runbook for an agent with database access + +**Status:** 🟢 **In execution — H1 scratch gate PASSED 2026-08-09** (see H1's result +block; prod apply rides **PR #404**, the owner's merge) · **Created:** 2026-08-08 · +**Owner:** vjvarada · +**Board row:** WS-29 · **Parent:** [`saas_multitenancy.md`](saas_multitenancy.md) · +**Shapes:** [`saas_multitenancy_implementation.md`](saas_multitenancy_implementation.md) + +> **What this document is.** Everything built so far was built in an environment with **no +> database and no Docker daemon**. That is why three migrations have never been applied and +> the RLS phases sit outside the deploy sequence. This is the runbook for an agent that +> *does* have a database — it owns **order, gates and verification**, not architecture. +> +> **If this doc and the parent disagree, the parent is right and this is stale.** + +--- + +## 0. Paste this to your agent first + +``` +You are executing WS-29 (multi-tenancy) on CommandCenter. + +READ IN THIS ORDER, FULLY, BEFORE TOUCHING ANYTHING: + 1. ai-company-brain/specs/saas_multitenancy_handover.md (this runbook — order + gates) + 2. ai-company-brain/specs/saas_multitenancy.md (§0.1, §0.9, §1, §5.1, §11) + 3. ai-company-brain/specs/saas_multitenancy_implementation.md (SQL + seam shapes) + 4. AGENTS.md at the repo root, then every AGENTS.md on the path to each file you edit + +NON-NEGOTIABLE: +- Work H1 → H8 IN ORDER. Each has a GATE that must pass before the next starts. +- H3 (RLS phase 4) is a CLIFF. If you apply it before H2 is complete and verified, + every query in the product returns zero rows. Do not reorder it. +- Never run `ruff check .` — this tree has ~1983 pre-existing errors. Lint only the + files you touched, and compare against HEAD to prove you introduced none. +- Never run `pytest tests/unit/` as a whole directory — it hangs against a live DB. + Name files. +- Verify every anchor (file:line) with grep before editing. This corpus has shipped + stale anchors repeatedly; two were found stale during this workstream alone. +- A test that SKIPS is not a test that PASSED. Use -v or -rs and read the skips. +- Do not git push to any branch other than claude/command-center-multitenant-a30fgy. + +Start with H1. Report the GATE result before moving on. +``` + +--- + +## 1. State of the tree, measured 2026-08-08 + +**Built and pushed** (branch `claude/command-center-multitenant-a30fgy`): + +| Ticket | State | Note | +|---|---|---| +| MT-0a per-run credentials | ✅ | ContextVar replaces process-global `os.environ` | +| MT-0b self-mutation containment | ✅ | migration **157** — scratch-applied + verified 2026-08-09 (H1); prod = PR #404 | +| MT-0c-1 no raw-SQL agent tools | ✅ | `query_history` rewritten; ratchet added | +| MT-0d per-org provider keys | ✅ | migration **158** — scratch-applied + verified 2026-08-09 (H1); prod = PR #404 | +| MT-1a control plane | ◐ | migration **159** — scratch-applied + verified 2026-08-09 (H1); identity cutover NOT done | +| MT-1b RLS | ◐ | generated into `infra/postgres/generated/`, **never applied** (H3's act, after H2 — the scratch DB `mt-scratch` is its test target) | +| MT-1c binding seam | ◐ | `tenant_session()` built; **561 call sites unconverted** | +| MT-1e Redis wrapper | ◐ | built; **~58 key sites unconverted** | +| MT-1i leak sites | ✅ | five predicates derived; one DB-backed criterion open | +| MT-0c-2 container tier | ⏸ | OWNER-GATE, parked by D16 until the pooled cutover | + +**Test baseline** — everything below should still pass when you finish: + +```bash +uv run pytest \ + tests/unit/test_integration_env_scoping.py \ + tests/unit/test_mt0b_self_mutation_containment.py \ + tests/unit/test_mt0c1_no_raw_sql_agent_tools.py \ + tests/unit/test_mt0d_per_org_credentials.py \ + tests/unit/test_tenant_placement.py \ + tests/unit/test_tenant_session.py \ + tests/unit/test_tenant_coverage.py \ + tests/unit/test_tenant_redis.py \ + tests/unit/test_db_engine_seam.py \ + tests/unit/test_psycopg_seam.py \ + tests/unit/test_org_access_control.py \ + tests/unit/test_app_grants.py -v -rs +``` + +⚠️ **Two of these SKIP without a database and are the whole reason you exist:** +`test_tenant_coverage.py::test_live_catalog_has_column_force_and_policy` and +`::test_app_role_cannot_bypass_rls`. **A green run that skips them proves the SQL was +written, not that it works.** + +--- + +## 2. The correction that changes the plan's size + +**The conversion surface is 561 sites across 138 files, not the "~200" the parent spec +says.** Measured 2026-08-08: + +```bash +grep -rhoE "await _?get_db\(\)" --include=*.py apps packages | wc -l # 561 +grep -rlE "await _?get_db\(\)" --include=*.py apps packages | wc -l # 138 +``` + +The undercount happened because the dominant idiom is the **aliased** import — +`from gateway.db import get_db as _get_db`, then `await _get_db()` (441 sites) — and a +grep for `get_db()` alone misses it. Heaviest files: `routes/tasks/items.py` (23), +`routes/notes/meeting_bot.py` (18), `routes/email/automation/runner.py` (15), +`rules.py` (15), `senders.py` (14), `routes/tasks/calendar.py` (13). + +**H2 is therefore the long pole of this whole workstream**, not H3. + +--- + +## 3. The gate sequence + +``` +H1 migrations 157/158/159 ──► H2 convert 561 call sites ──► H3 RLS phases 1-4 + │ +H4 background jobs ◄──────────────────────────────────────────────────┘ +H5 Redis conversion (parallel with H4) +H6 identity cutover (parallel with H4) +H7 subdomain resolution (after H6) +H8 blobs + partitioning (last; needs a window) +``` + +**The one ordering that is not negotiable: H2 before H3.** Everything else can move. + +--- + +## H1 · Apply and verify migrations 157, 158, 159 · 🟢 AGENT-SAFE + +Three migrations have **never touched a database**. They were statically checked only: +all four auto-generated constraint names confirmed against `schema.generated.sql`, and no +FK anywhere references the primary keys being re-pointed. + +**Do:** +1. Restore a **production dump into a scratch database**. Not an empty one — an empty + database hides every backfill and constraint problem these migrations can have. +2. Apply `157`, `158`, `159` in order. +3. Verify: + ```sql + -- 157 + SELECT slug, first_party FROM organization; -- default => true, others false + -- 158 + \d provider_keys -- PK (organization_id, provider) + SELECT count(*) FROM provider_keys WHERE organization_id IS NULL; -- 0 + \d model_config \d mcp_servers -- composite PKs + SELECT indexdef FROM pg_indexes WHERE indexname='plugins_org_name_key'; + -- 159 + SELECT count(*) FROM tenant_placement; -- = count(*) FROM organization + SELECT count(*) FROM user_identity; -- = distinct lower(email) in app_user + SELECT count(*) FROM org_membership; + ``` +4. Then apply them to production via the normal deploy. + +**Done when:** all three applied to a restored copy with the queries above returning the +stated values, **and** the baseline test set still passes. + +> ⚠️ **Known risk, stated so you look for it.** 158 does +> `ALTER TABLE ... DROP CONSTRAINT ... ADD PRIMARY KEY` on four tables. If any of them +> holds duplicate `(organization_id, )` rows the ADD fails and the migration aborts +> mid-file. Check for duplicates **before** applying, not after. +> +> ⚠️ 159's seed reads `app_user.display_name`. An earlier draft read `u.name` and would +> have failed on apply — that bug was caught by reading `schema.generated.sql:1579`, not by +> testing. **Assume there is another one and look.** + +**GATE:** production is on 157/158/159 and the baseline suite is green. + +> **H1 RESULT (2026-08-09) — scratch half PASSED; prod half is the owner's merge.** +> Executed on a local Docker scratch (`mt-scratch`, pgvector:pg16, 127.0.0.1:5433 — +> plan-guard makes every VPS/deploy path OWNER-GATE, so "restored production dump" +> became "full-ladder replica": 00→156 replayed clean, 154 files, zero failures, plus a +> synthetic seed exercising every backfill path — case-duplicate emails, NULL org, empty +> email, all four re-keyed credential tables). 157/158/159 applied clean, re-ran +> idempotently, and **every verify query below returned the stated value**. Baseline: +> **213 passed / 2 skipped** — after fixing a real defect this gate flushed out: +> `import litellm` runs `load_dotenv()` at import and planted a dev `.env`'s +> `DATABASE_URL` mid-collection, un-skipping the two DB gates against an unmigrated +> local DB (`tests/conftest.py` launch snapshot, commit `817596b5`). Red-check done: +> pointed at the migrated scratch, both H3 gates un-skip and fail on their real +> assertions. The prophesied "another `u.name`-style bug" was not found — but +> `schema.generated.sql` itself is **stale since ~migration 113**, so the static checks +> above were made against a stale artifact; the scratch replay is the real reference. +> **Remaining for the GATE, owner's acts:** (1) optionally repeat the apply on a scratch +> restored from the *production* dump (runbook in PR #404's description); (2) merge +> **PR #404** — deploy auto-applies via the ledger; verify by the three +> `- 15N_*.sql ... ok` deploy-log lines, never the job conclusion; (3) run the verify +> queries below against prod. H2 dispatches only after that. + +--- + +## H2 · Convert 561 session-acquisition sites to `tenant_session()` · 🟢 AGENT-SAFE · **the long pole** + +`acb_common.db.tenant_session()` exists and is tested. `get_db()` still exists, is +documented as **not** tenant-bound, and every one of the 561 sites still uses it. + +**Do it file by file, smallest first**, and commit per file or small group. Do **not** +attempt a mechanical repo-wide rewrite: `get_db()` returns a session the caller closes, +while `tenant_session()` is an async context manager that owns the transaction — the call +shape changes, not just the name. + +```python +# before +db = await _get_db() +try: + rows = await db.execute(text(SQL), params) +finally: + await db.close() + +# after +async with tenant_session() as db: + rows = await db.execute(text(SQL), params) +``` + +**Where the tenant comes from** — and this is the part to get right, not the mechanics: +- **Request handlers:** bind once, centrally, from the authenticated session. Add + `bind_tenant(user.organization_id)` in the gateway middleware / the app-wide dependency + that already resolves `UserContext`, and release it after the response. Then the 561 sites + need no tenant argument at all. **Do this before converting any handler.** +- **Jobs, brokers, consumers:** H4. Do not let a job inherit an ambient tenant. +- **Never** from a header, query parameter or body field — `user_management_contract.md` + **R11**. + +**Done when:** +1. `grep -rE "await _?get_db\(\)" --include=*.py apps packages` returns **0**. +2. A ratchet in `tests/unit/test_db_engine_seam.py` (or a sibling) fails the build if + `get_db` is called again outside its own module. +3. The baseline suite passes, plus every app's own tests. +4. Manual smoke: sign in, open Chat / Email / Tasks / CRM / Projects / Notes, confirm each + returns data. **A missing tenant binding presents as an empty list, not an error** — + that is the fail-closed property, and it means an empty screen is the symptom you are + hunting for. + +**GATE:** zero `get_db()` call sites, ratchet in place, product verified working. + +> This is where the work actually is. Expect it to dominate the schedule, and resist the +> temptation to do H3 first because it looks like the interesting part. + +--- + +## H3 · Apply the RLS phases · 🟢 AGENT-SAFE to apply · ⚠️ **PHASE 4 IS A CLIFF** + +`infra/postgres/generated/{01,02,03,04}` — regenerate with +`uv run python scripts/gen_tenant_migration.py`. **135 tables, 11 exempt.** + +These are outside the deploy sequence on purpose. `apply_migrations.sh` carries a +lock-timeout design written after a **14h44m outage** where a hung session held a lock, the +runner queued an `ACCESS EXCLUSIVE` behind it, and Postgres's FIFO lock queue put every +later reader behind the *waiting* ALTER. Sending mail stopped. This is that shape, 135 +times over. + +**Before phase 1:** create the app role. +```sql +CREATE ROLE acb_app LOGIN PASSWORD :'pw' NOINHERIT; -- NOT superuser, NOT owner, NOT BYPASSRLS +GRANT CONNECT ON DATABASE :db TO acb_app; +GRANT USAGE ON SCHEMA public TO acb_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO acb_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO acb_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO acb_app; +``` +Point the gateway, orchestrator and ingestion processes at `acb_app`. **Migrations keep +running as the owner.** + +| Phase | What | Safety | +|---|---|---| +| 1 `add_columns` | nullable ADD COLUMN | no scan, no meaningful lock — safe live | +| 2 `backfill` | batched UPDATE | re-runnable and interruptible; the slow one | +| 3 `constraints` | SET NOT NULL + FK + index | **ACCESS EXCLUSIVE, scans every table — window required.** Apply table-by-table if needed. Never behind a long transaction | +| 4 `policies` | ENABLE + FORCE + policy | instant — **and the cliff** | + +> 🚨 **Phase 4 gate.** The instant it applies, any connection that has not bound +> `app.tenant_id` reads **zero rows**. That is `§0.1`'s fail-closed property working as +> designed. **H2 must be complete and verified in production before you run phase 4.** If +> you are unsure whether H2 is complete, you are not ready. +> +> **Rollback for phase 4** (fast, and it is the only phase you will want to roll back): +> ```sql +> ALTER TABLE DISABLE ROW LEVEL SECURITY; -- per table, or scripted across the 135 +> ``` +> Phases 1–3 are additive and do not need rolling back. + +**Done when:** all four phases applied, **and** +```bash +DATABASE_URL=... uv run pytest tests/unit/test_tenant_coverage.py -v -rs +``` +shows the two previously-skipped tests as **PASSED**, not skipped. + +**GATE:** those two tests pass against the live catalog. + +--- + +## H4 · Bind a tenant in every background job (MT-1d) · 🟢 AGENT-SAFE + +*"A job that forgets doesn't leak one row; it leaks unbounded."* + +Jobs have no request, so no session to inherit from. Cover: the ingestion scheduler +(`email_ingestion/scheduler.py`, three engines), `inbound.py`, the Redis Streams consumer +(`ingestion/consumer.py`), the reconciler, orchestrator agent runs, and broker handlers. + +**Done when:** every queued/scheduled unit carries `organization_id` on its record and +binds it before any DB access; a job constructed **without** one **refuses to run** rather +than defaulting; a test proves the refusal. + +Also here: **`scripts/import_hr_people.py:177`** (§0.1 path 9) — it opens its own engine and +**upserts people rows**, which are tenant data. It must take a tenant from argv. Under +phase-4 policies it currently writes unowned rows or fails. + +--- + +## H5 · Convert the Redis key sites (MT-1e remainder) · 🟢 AGENT-SAFE + +`acb_common/tenant_redis.py` is built and cannot express an unprefixed key. **~58 key sites +across 10 clients** still bypass it. The module docstring holds the migration path. +**Do not write a dual-read shim** — every key is cache, presence or a bounded stream, so +conversion is a cache-cold event, not a data migration. + +⚠️ **Three things the ratchets structurally cannot catch. Handle each by hand:** +1. **`routes/chat.py:707`** — `SCAN match="cc:active:*"` **enumerates every tenant's + sessions** once a second exists. Highest severity in the inventory. Fix first. +2. **`ingestion/consumer.py:95`** — `_GROUP = "cc-ingest"` is **one consumer group shared by + all tenants**. §1.9 requires one per tenant. +3. **Untenanted non-`cc:` namespaces**, invisible to the `cc:` ratchet: + `ingestion:{clickup,zoho,gmail,dlq}` (`queue.py`), `session_mem:` + (`acb_memory/session_cache.py:34`), `email:att:cache:` (`attachments.py`). Plus + **`orchestrator/agents.py:436`**, which hands `redis_url` to `agent_framework`'s + `RedisHistoryProvider` — chat history keyed **outside this wrapper entirely**. That one + needs a decision, not a conversion. + +**Done when:** both allow-lists in `test_tenant_redis.py` are empty and its stale-entry +tests still pass; the three items above are individually resolved and each resolution +recorded. + +--- + +## H6 · Identity cutover (MT-1a-2) · 🟡 CAREFUL — live sign-in path + +Migration 159 created `user_identity` + `org_membership` and seeded them. `app_user` is +still authoritative and **nothing reads the new tables**. + +⚠️ **The two upserts that block this:** `acb_auth/access.py:205` +(`ON CONFLICT (lower(email))`) and `:509` (`ON CONFLICT (email)`). Both are on the live +sign-in path. **Re-derive both line numbers with grep** — the parent spec cited them in +`members.py`, where they are not. + +⚠️ **The invite path is an account-takeover primitive under two orgs** — an +`INSERT … ON CONFLICT (email) DO UPDATE SET organization_id = …` moves a human between +tenants. It must become an insert into `org_membership`, never an update of the identity. + +**Done when:** sign-in resolves through `user_identity` + `org_membership`; a test proves +one email can hold active membership in **two** organizations; the invite path cannot move +an existing identity between orgs; `app_user` reads are gone or reduced to a compatibility +view. + +--- + +## H7 · Subdomain tenant resolution (MT-1f) · 🟢 AGENT-SAFE · after H6 + +**Done when:** the workbench resolves `.`; the tenant claim rides the +**authenticated session**; the gateway derives the tenant from the session or a +tenant-scoped API key **only**; and a test asserts an `X-Organization-Id` header, query +parameter or body field is **ignored, not honoured** (R11). + +> The subdomain is a **lookup to verify against the session**, never an assertion. A +> trusted subdomain is a header with a friendlier name. + +--- + +## H8 · Blobs and partitioning (MT-1g, MT-1h) · 🟡 needs a window + +- **MT-1g:** `agent_blob.content` is `BYTEA` — file content inside Postgres + (`71_agent_blob_store.sql:30`). Move to object storage keyed `/…`; table keeps + metadata + pointer. Worth doing in any tenancy model. +- **MT-1h:** partition the heavy tables on `organization_id` — **LIST for the largest + tenants, HASH/default for the tail.** One partition per tenant across all tenants + recreates the catalog pressure §1.8 rejects. Targets: `email_messages`, the + `*_embeddings` vector tables, `chat_message`, `audit_event`. +- **Per-tenant logical backup is a required capability, not a side effect.** "Restore this + one customer to yesterday" must be answerable, and `pg_restore` on a pooled instance + cannot answer it. Build the export/import job and **run it end to end at least once.** + +--- + +## 4. Open criteria that need your database + +Carried here so they are not lost in the parent spec: + +1. **`tenancy_and_visibility.md` §2 done-when 3** — the DB-backed two-org behavioural + fixture for the `group:` expansion. Lives in `test_session_authority.py` / + `test_rooms.py`, both of which **skip entirely** without Postgres (21 skipped). Seed two + `organization` rows with identically-slugged `org_group`s and disjoint members; assert + org A's expansion never admits org B's member. **The PR must quote a run showing + `passed`, never `skipped`.** +2. **`test_tenant_coverage.py`'s two DB tests** — H3's gate. +3. **The Mem0 decision (§0.1 path 8)** — conninfo options, a per-tenant role, or + scope-string-only isolation. §2.4 of the implementation spec has the three options and + their costs. **Leaving it undecided fails MT-1c.** + +--- + +## 5. Standing rules — violating these is how this goes wrong + +1. **`SET LOCAL`, never `SET`.** A session-scoped `SET` survives the connection's return to + the pool and the next borrower — a different customer — inherits it. +2. **`SET LOCAL` needs a real transaction.** Outside one it is a *silent no-op*: every + query then returns nothing, which reads as "the feature is broken", not "tenancy is + broken". +3. **Fail closed, everywhere.** Unresolvable tenant → refuse. Never "the usual one". Four + modules already implement this identically (`key_store._resolve_org`, + `mutation._self_mutation_permitted`, `placement.resolve_placement`, + `db.tenant_session`) — match them. +4. **No agent ever gets a raw-SQL tool**, and no agent-reachable path may set + `app.tenant_id`. §0.9.3 makes this a **condition on the pooled decision**. Violate it and + §1 must be re-taken. +5. **No second scoping doctrine.** Tenant isolation is `organization_id` + RLS; visibility + *inside* a tenant stays `email | group: | org` and is unchanged. +6. **Re-derive every anchor with grep.** Two published anchors were found stale during this + workstream — one by 62 lines. +7. **Never `ruff check .`** (~1983 pre-existing). Never `pytest tests/unit/` as a directory + (hangs on a live DB). **A skip is not a pass.** + +--- + +## 6. What is deliberately NOT in scope + +- **MT-0c-2, the container/microVM agent tier.** 🔴 OWNER-GATE, parked by **D16**. D10's + "trusted colleagues" threat model survives the silo phase; T2 becomes a precondition of + the **§5.1 pooled cutover** (customer 8–12), not of Phase 0. **An agent must refuse to + build it and say so.** +- **MT-2 … MT-5** (entitlements, AI credits, billing). Blocked on owner inputs, not on + engineering: the SKU list and price points, the credit-to-rupee rate and target margin, + and the payment-provider split. §11's table names each. **Do not invent them** — the spec + contract forbids acceptance criteria an implementer cannot test. + +--- + +## 7. References + +Parent: [`saas_multitenancy.md`](saas_multitenancy.md) — §0.1 connection inventory (ten +paths) · §0.9 the three planes · §1 tenancy · §5.1 rollout · §6 blockers · §11 tickets. +Shapes: [`saas_multitenancy_implementation.md`](saas_multitenancy_implementation.md) — +§1 migration templates · §2 the seam · §3 control plane · §6 sandbox · §7 runbooks · +§8 the ten-row trap table. +Binding: [`user_management_contract.md`](user_management_contract.md) **R11** · +[`tenancy_and_visibility.md`](tenancy_and_visibility.md) §2–§5 (visibility, unchanged) · +`work_plan.md` WS-29 · **D15** · **D16** · §6 owner-gate registry. diff --git a/ai-company-brain/specs/saas_multitenancy_implementation.md b/ai-company-brain/specs/saas_multitenancy_implementation.md new file mode 100644 index 000000000..bdf3da96e --- /dev/null +++ b/ai-company-brain/specs/saas_multitenancy_implementation.md @@ -0,0 +1,608 @@ +# Multi-tenancy implementation reference — the shapes, not the reasons + +**Status:** 🟢 Binding build reference · **Created:** 2026-08-08 · **Owner:** vjvarada · +**Board row:** WS-29 · **Parent spec:** [`saas_multitenancy.md`](saas_multitenancy.md) · +**Verified against code:** 2026-08-08, working tree at `b09093a` + +> **This document owns SHAPES, not DECISIONS.** Every decision it implements is owned by +> `saas_multitenancy.md` and is cited, never re-argued here: +> +> | Decision | Owner | +> |---|---| +> | Tenant = row + RLS; deployment = placement (D15) | `saas_multitenancy.md` §1 | +> | The three planes; the agent sandbox contract | §0.9 | +> | The eight connection paths | §0.1 | +> | Entitlements ≠ permissions | §2 | +> | Credits, rate card, metering | §3 | +> | Billing, ledger, reconciliation | §4 | +> | The tickets (MT-0 … MT-5) and their done-when | §11 | +> | Visibility *inside* a tenant (unchanged) | `tenancy_and_visibility.md` §3–§5 | +> +> **If this doc and the parent disagree, the parent is right and this doc is stale — fix +> it here.** What lives here and nowhere else is the *executable shape*: the SQL, the seam +> code, the test ratchets and the runbooks an implementer needs so that MT-n does not have +> to be re-derived per ticket. +> +> ⚠️ **Every SQL block below is a TEMPLATE, not a migration.** R1 binds: migration numbers +> are resolved at build time, never written into a doc. Anchors are re-verified at +> dispatch — §0.1 of the parent exists because that rule was broken once already. + +--- + +## 1. MT-1b — the tenancy migration, generated + +### 1.1 The per-table template + +Applied to every application table. **Generate it; do not hand-write 143 of these.** + +```sql +-- ① the column, with the default that keeps INSERTs unchanged +ALTER TABLE {t} + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +-- ② backfill the single existing org, then make it NOT NULL +UPDATE {t} SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; +ALTER TABLE {t} ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE {t} ADD CONSTRAINT {t}_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; + +-- ③ the policy. ENABLE alone is not enough — the table OWNER bypasses it. +ALTER TABLE {t} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {t} FORCE ROW LEVEL SECURITY; +CREATE POLICY {t}_tenant_isolation ON {t} + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +-- ④ the index. org_id FIRST — it is a distribution key, not a filter column (§1.8a) +CREATE INDEX IF NOT EXISTS {t}_org_idx ON {t} (organization_id); +``` + +**Four things in that template are load-bearing and each has an incident waiting behind +it if dropped:** + +| Clause | Drop it and | +|---|---| +| `DEFAULT current_setting(…)` | every `INSERT` in 209 gateway files needs editing — the whole "zero queries change" property is this one line | +| `FORCE ROW LEVEL SECURITY` | the table owner (and therefore anything connecting as it) reads every tenant, silently | +| `WITH CHECK` | a tenant can **write** a row stamped with another tenant's id. `USING` filters reads; only `WITH CHECK` constrains writes | +| `, true` in `current_setting` | an unset GUC **raises** instead of returning NULL. Raising is *also* fail-closed, but it turns "no rows" into a 500 on every unconverted path and makes MT-1c undiagnosable | + +### 1.2 Composite primary keys — the distribution-key discipline + +Parent §1.8a. `organization_id` goes **first** in every PK and every composite index: + +```sql +-- before -- after +PRIMARY KEY (id) PRIMARY KEY (organization_id, id) +PRIMARY KEY (app_id, subject) PRIMARY KEY (organization_id, app_id, subject) +INDEX (user_id, created_at DESC) INDEX (organization_id, user_id, created_at DESC) +``` + +**Do this in MT-1b or never.** Changing a PK after data exists means rewriting every +referencing FK; it is the one part of this plan that is genuinely expensive to defer. + +> ⚠️ **Watch the FK graph.** Promoting a PK to composite forces every referencing FK to +> become composite too. Generate both sides in one pass; a half-converted FK graph does +> not apply. + +### 1.3 The app role + +```sql +CREATE ROLE acb_app LOGIN PASSWORD :'pw' NOINHERIT; +-- NOT superuser. NOT the table owner. NOT BYPASSRLS. All three bypass policies. +GRANT CONNECT ON DATABASE :db TO acb_app; +GRANT USAGE ON SCHEMA public TO acb_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO acb_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO acb_app; -- covers future tables +GRANT ALLOW ON ALL SEQUENCES IN SCHEMA public TO acb_app; -- adjust to usage/select +``` + +Migrations keep running as the owner (`scripts/apply_migrations.sh` is unchanged). +**Only the gateway, ingestion and orchestrator processes switch to `acb_app`.** + +### 1.4 The coverage ratchet (MT-1b done-when 4) + +Same discipline as `tests/unit/test_db_engine_seam.py`. **Source-level would not work +here** — the failure mode is a table, not a call site — so this one is DB-backed and must +be run against a migrated database in CI. + +```python +_EXEMPT = { + # Control-plane tables: cross-tenant BY DESIGN (parent §1.5). Each needs a reason. + "organization", "tenant_placement", "user_identity", + "org_subscription", "org_module_entitlement", "user_module_seat", + "module_catalog", "usage_event", "usage_rollup", "credit_ledger", "invoice", + "feature_catalog", # a catalog, identical for every tenant + "schema_migrations", +} + +async def test_every_table_is_tenant_scoped(db): + rows = await db.execute(text(""" + SELECT c.relname, + EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = c.relname AND column_name = 'organization_id') AS has_col, + c.relrowsecurity AS rls_enabled, + c.relforcerowsecurity AS rls_forced, + EXISTS (SELECT 1 FROM pg_policies p WHERE p.tablename = c.relname) AS has_policy + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind = 'r' + """)) + bad = [r.relname for r in rows + if r.relname not in _EXEMPT + and not (r.has_col and r.rls_enabled and r.rls_forced and r.has_policy)] + assert not bad, f"tables missing tenant scoping: {bad}" +``` + +> **The exemption list is the security review.** Adding a name to `_EXEMPT` must require a +> written reason in the same PR — it is the only way a table legitimately escapes, and +> therefore the only way one illegitimately does. + +--- + +## 2. MT-1c — binding the tenant at all eight paths + +Parent §0.1 holds the inventory. These are the shapes. + +### 2.1 The shared async seam — `acb_common/db.py` + +```python +_TENANT: ContextVar[str | None] = ContextVar("acb_tenant", default=None) + +@asynccontextmanager +async def tenant_session(tenant_id: str | None = None): + """The ONLY way to obtain a tenant-bound session. + + ``SET LOCAL`` — never ``SET``. The pool recycles connections across requests + (``db.py`` pool_size + max_overflow), and a session-scoped ``SET`` survives the + connection's return to the pool: the next borrower reads the previous tenant. + ``SET LOCAL`` is transaction-scoped and resets on commit or rollback. + """ + tid = tenant_id or _TENANT.get() + if not tid: + raise TenantUnbound("no tenant in context — a caller outside a request or job " + "must pass one explicitly") + session = get_session_factory()() + try: + await session.begin() # SET LOCAL needs a transaction + await session.execute( + text("SET LOCAL app.tenant_id = :t"), {"t": str(tid)}) + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() +``` + +> ⚠️ **`SET LOCAL` outside a transaction is a silent no-op** — Postgres warns and moves on, +> the policy then sees an unset GUC, and every query returns zero rows. That presents as +> "the feature is broken", not as "tenancy is broken", which is why the explicit +> `session.begin()` is in the template rather than left to SQLAlchemy's autobegin. + +**`get_db()` keeps its name and signature** so the ~200 `db = await get_db()` call sites +do not change; it becomes a thin wrapper that raises when no tenant is bound. + +### 2.2 The two new ratchets (MT-1c done-when 2 and 3) + +`test_db_engine_seam.py` inspects `create_async_engine` **only**. Extend it and add a +sibling: + +```python +_SYNC_ENGINE_ALLOWED = { + "packages/acb_graph/acb_graph/db.py": "entity graph; sync by design — MUST bind tenant", +} +_PSYCOPG_ALLOWED = { + "packages/acb_common/acb_common/org_settings.py": "control-plane read, pre-tenant", + "packages/acb_llm/acb_llm/model_config.py": "control-plane read, pre-tenant", + "packages/acb_llm/acb_llm/key_store.py": "per-org keys after MT-0d — MUST bind", +} +# Same AST walk as the existing test; assert every psycopg.connect / create_engine +# call site is in its allow-list, with a reason string. +``` + +**The allow-list entry is the design review.** Parent §0.1 exists because two paths had no +ratchet at all and were therefore invisible. + +### 2.3 The fail-closed test (MT-1c done-when 5) + +The single most important test in the whole workstream: + +```python +async def test_unbound_connection_returns_no_rows(raw_session): + """An unconverted path must fail CLOSED — zero rows, not another tenant's rows. + + This is the property the entire pooled decision rests on (parent §0.1). If this + test ever goes green for the wrong reason, the architecture is unsound. + """ + await raw_session.execute(text("RESET app.tenant_id")) + rows = (await raw_session.execute(text("SELECT * FROM gtd_items LIMIT 5"))).all() + assert rows == [] +``` + +### 2.4 Mem0 — the decision MT-1c must record + +Path 8 hands a conninfo string to a third-party client. **DECIDED 2026-08-09: Option A +(D17, `agent-proposed, owner may overrule` — recorded in `work_plan.md` §3 and the +parent's §0.1 consequence 4).** The three options, kept for the record: + +| Option | Shape | Cost | +|---|---|---| +| **A — conninfo options** *(recommended)* | append `options=-c app.tenant_id=` to the conninfo Mem0 receives; the GUC rides the startup packet | Mem0 needs a connection per tenant, so pool per tenant or reconnect per scope change | +| **B — role per tenant** | `SET ROLE tenant_`, policies keyed on `current_user` | N roles to provision and drop; onboarding gains a DDL step | +| **C — scope-string only** | accept that Mem0 isolation rests on the existing scope strings, not RLS | Cheapest, weakest. **Only acceptable if written down as an accepted risk with a date** | + +--- + +## 3. MT-1a — the control plane + +Separate database (or at minimum a separate schema with its own role). **No RLS here — +it must read across tenants.** Parent §1.5. + +```sql +CREATE TABLE organization ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL, -- the subdomain + display_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('trial','active','past_due','suspended','cancelled')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE tenant_placement ( + organization_id UUID PRIMARY KEY REFERENCES organization(id) ON DELETE CASCADE, + tier TEXT NOT NULL CHECK (tier IN ('pool','bridge','silo')), + target TEXT NOT NULL, -- connection alias, NOT a raw URL with a password + region TEXT NOT NULL DEFAULT 'ap-south-1' +); +-- Day one every row is ('pool', 'primary'). The indirection is the point: +-- it turns "move this customer to their own database" into a data move (parent §1.6). + +CREATE TABLE user_identity ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, -- global; one row per human + display_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE org_membership ( + organization_id UUID NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES user_identity(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('invited','active','suspended','removed')), + invited_by TEXT, invited_at TIMESTAMPTZ, + joined_at TIMESTAMPTZ, last_active_at TIMESTAMPTZ, + PRIMARY KEY (organization_id, user_id) +); +``` + +> ⚠️ **`app_user.email` global uniqueness is load-bearing today** — two `ON CONFLICT (email)` +> upserts on the live sign-in path, measured 2026-08-08 at `acb_auth/access.py:205` and `:509` +> *(the `members.py:173`/`access.py:447` pair first published here was stale — corrected +> 2026-08-09; re-derive with grep)*. Both are rewritten by MT-1a. +> The invite path is the dangerous one: today's +> `INSERT … ON CONFLICT (email) DO UPDATE SET organization_id = EXCLUDED.organization_id` +> is, under two orgs, **an account-takeover primitive** (`tenancy_and_visibility.md` §1.1 +> site 3). It must become an insert into `org_membership`, never an update of the identity. + +**Email-keyed columns across the tenant plane stay email-keyed.** `app_grants.subject`, +`apps.owner_email`, `gtd_items.user_id`, `meeting.owner_email` do **not** get re-keyed to +UUIDs — RLS already constrains the row set to one tenant, so `email` is unambiguous within +it. That is the second reason to do RLS before the identity split. + +--- + +## 4. MT-2 — entitlement shapes + +Control-plane tables. Parent §2.2 owns the reasoning. + +```sql +CREATE TABLE module_catalog ( + slug TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + feature_slugs TEXT[] NOT NULL, -- unlocks these feature_catalog rows + requires TEXT[] NOT NULL DEFAULT '{}', + is_core BOOLEAN NOT NULL DEFAULT false, + list_price_per_seat_month NUMERIC(12,2), + currency TEXT NOT NULL DEFAULT 'INR' +); + +CREATE TABLE org_module_entitlement ( + organization_id UUID NOT NULL, + module_slug TEXT NOT NULL REFERENCES module_catalog(slug), + state TEXT NOT NULL CHECK (state IN + ('trial','active','past_due','suspended','cancelled')), + seats_purchased INT NOT NULL DEFAULT 0, + effective_from TIMESTAMPTZ NOT NULL DEFAULT now(), + effective_until TIMESTAMPTZ, + source TEXT NOT NULL, -- stripe | razorpay | manual + PRIMARY KEY (organization_id, module_slug) +); + +CREATE TABLE user_module_seat ( + organization_id UUID NOT NULL, + user_id UUID NOT NULL, + module_slug TEXT NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + assigned_by TEXT, + PRIMARY KEY (organization_id, user_id, module_slug) +); + +CREATE TABLE org_feature_flag ( -- §1.4b: release channel + per-org flags + organization_id UUID NOT NULL, + flag TEXT NOT NULL, + enabled BOOLEAN NOT NULL, + set_by TEXT, set_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, flag) +); +``` + +### 4.1 The enforcement seam — zero route edits + +`EffectiveAccess.intersect()` already exists (`packages/acb_auth/acb_auth/permissions.py:366-374`) +and already narrows an agent to its member. Entitlements are the same operation: + +```python +def entitlement_mask(org_id: str) -> EffectiveAccess: + """feature:* grants for the modules this org currently owns. Redis-cached, + invalidated by the billing webhook. NEVER a payment-provider call on the + request path (parent §4).""" + slugs = [f for m in active_modules(org_id) for f in m.feature_slugs] + return EffectiveAccess(grants=frozenset(f"feature:{s}" for s in slugs) | {"agents:run:*"}) + +# in acb_auth.deps, where EffectiveAccess is already resolved once per request: +effective = role_and_override_access.intersect(entitlement_mask(user.organization_id)) +``` + +Every existing `require_permission("feature:crm")` and the whole nav then honour +entitlements **with no route changes.** + +### 4.2 402 vs 403 + +```python +class NotEntitled(HTTPException): + """The ORG does not own this module → 402. Action: upgrade.""" + def __init__(self, module: str): + super().__init__(status_code=402, detail={ + "error": "module_not_entitled", "module": module, + "upgrade_url": f"/settings/billing?module={module}"}) +``` + +403 keeps its meaning: signed in, org owns it, **your admin** has not granted it. The +frontend routes the two differently — *upgrade* vs *ask your admin* — and `/auth/me` +returns both `features` and `modules` so it can. + +### 4.3 The non-HTTP gate (MT-2, the part everyone forgets) + +An unowned module must not run its background work. Otherwise it is dark in the UI while +its email sync still polls every five minutes **on your provider spend, for a customer who +is not paying.** Gate at four places: + +```python +# agent registry · ingestion scheduler · Redis stream consumer · workflow triggers +if not is_entitled(org_id, "email"): + continue # skip this tenant's slice of the sweep +``` + +--- + +## 5. MT-3 — metering shapes + +```sql +CREATE TABLE llm_api_key ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + prefix TEXT NOT NULL UNIQUE, -- 'cc_live_a8f3…' — the lookup key + key_hash TEXT NOT NULL, -- argon2/sha256; never the key + label TEXT, scopes TEXT[], + created_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ +); + +CREATE TABLE model_rate_card ( + model TEXT NOT NULL, + input_credits_per_1k NUMERIC(12,4) NOT NULL, + output_credits_per_1k NUMERIC(12,4) NOT NULL, + cached_input_credits_per_1k NUMERIC(12,4) NOT NULL, + effective_from TIMESTAMPTZ NOT NULL, + PRIMARY KEY (model, effective_from) +); + +CREATE TABLE usage_event ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + user_email TEXT, agent TEXT, module_slug TEXT, + model TEXT, tier TEXT, + prompt_tokens INT, completion_tokens INT, cached_tokens INT, + provider_cost_usd NUMERIC(14,8), -- what it cost YOU + billed_credits NUMERIC(14,4), -- what you charge THEM + request_id TEXT NOT NULL UNIQUE, -- ← idempotency. Not decoration. + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE credit_ledger ( -- APPEND-ONLY. Never UPDATE a balance. + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + delta NUMERIC(14,4) NOT NULL, -- + top-up, − consumption + reason TEXT NOT NULL, ref TEXT, + balance_after NUMERIC(14,4) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +### 5.1 Where the four hooks go + +All four attach to the shipped choke point — `gateway/routes/v1_compat.py` and +`acb_llm/client.py::_emit_usage` (`:552`), which already computes tokens, cache stats and +USD cost, streaming included (`v1_compat.py:563-573`). + +``` +request → require_llm_api_auth → ① resolve org from the key PREFIX (replaces the + single box-wide LITELLM_MASTER_KEY at deps.py:448-472) + → pre-flight → ② Redis DECR against balance; 402 if exhausted + → provider call → (unchanged) + → _emit_usage → ③ usage_event INSERT … ON CONFLICT (request_id) DO NOTHING + ④ credits = tokens ÷ 1000 × rate_card +``` + +> ⚠️ **`request_id` uniqueness is not decoration.** Retries, reconnects and the streaming +> rebuild path all create double-write opportunities, and a customer billed twice for one +> call is a credibility event. +> +> ⚠️ **The gate is Redis, the ledger is Postgres.** The pre-flight check is on the hot path +> of every token; a Postgres round-trip there is a latency regression on every LLM call in +> the product. + +--- + +## 6. MT-0c — the agent sandbox contract + +🔴 **OWNER-GATE** — parent §0.9.3 and `work_plan.md` §6. Shape recorded so the ticket is +ready the moment it is un-parked; **do not build it before then.** + +``` +┌─ agent run ─────────────────────────────────────────────────┐ +│ IN: tenant_id · run_id · scoped credentials (expiring) │ +│ tool manifest · workspace mount │ +│ OUT: tool calls over a tenant-bound API │ +│ │ +│ ✗ NO database connection, driver or connection string │ +│ ✗ NO ambient env credentials (MT-0a is the prerequisite) │ +│ ✗ NO egress except an allowlist │ +│ ✗ NO raw-SQL tool, ever, at any tier │ +│ ✓ destroyed at end of run │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Assertion that must exist before any external tenant:** + +```python +def test_agent_process_holds_no_database_credentials(agent_env): + """Parent §1.8a makes this a CONDITION on the pooled decision, not a nicety. + An agent that cannot open a connection cannot escape RLS, cannot set + app.tenant_id, and cannot be SQL-injected into another tenant.""" + assert "DATABASE_URL" not in agent_env + assert not any(k.endswith(("_DSN", "_URL")) and "postgres" in str(v).lower() + for k, v in agent_env.items()) +``` + +Implementation ladder: container + seccomp + no-network-by-default → gVisor → Firecracker. +**Start at the first; design so the third is a swap.** Warm pools are an optimisation and +must never become the isolation boundary. + +--- + +## 7. Runbooks + +### 7.1 Onboarding a tenant (pooled) + +``` +1. INSERT organization (slug, display_name) -- slug = subdomain +2. INSERT tenant_placement (tier='pool', target='primary') +3. Seed org_role + org_role_permission (the 5 system roles) FOR THAT ORG + ⚠️ 130_org_access_control.sql seeds by `slug='default'` — that seeding must + become a parameterised function, or org #2 has no roles and no owner. +4. INSERT user_identity (if new) + org_membership (owner) +5. Grant the `core` module entitlement + assign the owner a seat +6. Issue an llm_api_key; credit the trial balance in credit_ledger +7. Verify: sign in at ., /auth/me returns the expected + features AND modules +``` + +> ⚠️ **`ensure_owner_bootstrap()` is a permanent no-op once any owner exists anywhere** +> (`access.py:522`, `_HAS_OWNER_SQL`, no org filter). Until MT-1i lands, **step 3 above +> silently produces an ownerless org** — a lockout, not a leak. This is the single most +> likely way tenant #2 fails. + +### 7.2 Cutting a silo customer over to pooled — 🔴 OWNER-GATE to execute + +Only works because every silo runs the pooled schema from day one (parent §5.1 +condition 2). If it did not, this is a rewrite rather than a runbook. + +``` +1. Freeze writes (maintenance flag) +2. pg_dump --data-only the silo, with its organization_id already populated +3. Load into the pooled cluster; verify row counts per table +4. Copy blobs: /… → /… in object storage +5. Repoint tenant_placement.target → 'primary' +6. Smoke: sign in, /auth/me, one read per owned module +7. Unfreeze. Keep the silo cold for 7 days — do not decommission on the same day +``` + +### 7.3 Verifying isolation before the first external tenant + +```bash +# every table is scoped +uv run pytest tests/unit/test_tenant_coverage.py -v -rs +# unbound access returns nothing, not someone else's rows +uv run pytest tests/unit/test_tenant_binding.py -v -rs +# the ratchets +uv run pytest tests/unit/test_db_engine_seam.py tests/unit/test_psycopg_seam.py -v +# the agent holds no DB credentials (MT-0c) +uv run pytest tests/unit/test_agent_isolation.py -v +# manual, two-org fixture: A cannot see B on every owned surface +uv run pytest tests/integration/test_cross_tenant.py -v -rs +``` + +> ⚠️ **Never run these against production.** `test_owner_bootstrap.py` already carries that +> warning (`tenancy_and_visibility.md` §7) and it applies to every test here. + +--- + +## 8. The traps, each with the thing that will find it + +| # | Trap | How it presents | +|---|---|---| +| 1 | `SET` instead of `SET LOCAL` | Works in dev (one connection). In prod, a random request reads the previous borrower's tenant. **Load-test with a small pool to reproduce** | +| 2 | `SET LOCAL` outside a transaction | Silent no-op → every query returns zero rows → reads as "the feature is broken" | +| 3 | App connects as table owner | Every test passes; RLS never applies. **`test_tenant_coverage` checks `relforcerowsecurity`, not just `relrowsecurity`, for exactly this** | +| 4 | A background job with no tenant | Not one row — **unbounded**. Job records must carry `organization_id` and refuse to run without it | +| 5 | Role seeding still keyed `slug='default'` | Org #2 gets no roles and no owner. Runbook 7.1 step 3 | +| 6 | `_HAS_OWNER_SQL` unfiltered | Org #2 is ownerless and nobody can grant access back — **a lockout RLS does not fix** | +| 7 | Redis key without a tenant prefix | Cross-tenant cache/presence bleed, invisible to every DB test. **MT-1e's wrapper is the fix; a convention is not** | +| 8 | `usage_event` without `request_id` unique | Double billing on retry | +| 9 | Entitlement checked in the UI only | Module dark in nav, scheduler still polling, still costing you money | +| 10 | Composite PKs deferred | Cheap in MT-1b, needs a full FK-graph rewrite afterwards | + +--- + +## 9. Verification + +```bash +# §2.1 — the seam, and that nothing else creates an engine +grep -n "SET LOCAL\|def tenant_session\|def get_db" packages/acb_common/acb_common/db.py +grep -rn "create_async_engine\|create_engine(\|psycopg.connect" --include=*.py apps packages + +# §1.3 — the app role has no bypass +psql -c "SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname='acb_app'" + +# §1.1 — policies are FORCED, not merely enabled +psql -c "SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class + WHERE relkind='r' AND relnamespace='public'::regnamespace AND NOT relforcerowsecurity" + +# §4.1 — the intersect seam entitlements reuse +grep -n "def intersect" -A 12 packages/acb_auth/acb_auth/permissions.py + +# §5.1 — the metering choke point +grep -n "def _emit_usage" -A 30 packages/acb_llm/acb_llm/client.py +grep -n "require_llm_api_auth" -A 25 packages/acb_auth/acb_auth/deps.py +``` + +--- + +## 10. References + +**Parent (owns every decision here):** [`saas_multitenancy.md`](saas_multitenancy.md) — +§0.1 connection inventory · §0.9 the three planes and the sandbox contract · §1 tenancy · +§2 entitlements · §3 credits · §4 billing · §5.1 rollout · §6 blockers · §11 tickets. + +**Binding neighbours:** [`user_management_contract.md`](user_management_contract.md) (R11 +is the tenant-resolution rule this doc implements) · +[`tenancy_and_visibility.md`](tenancy_and_visibility.md) §2–§5 (visibility *inside* a +tenant — unchanged and still binding) · +[`org_access_control.md`](org_access_control.md) (the shipped RBAC these shapes extend) · +[`permissions_sandbox_b6.md`](permissions_sandbox_b6.md) (P5-c is MT-0c) · +`work_plan.md` WS-29 · D15 · §6 owner-gate registry. diff --git a/ai-company-brain/specs/skills_registry.md b/ai-company-brain/specs/skills_registry.md index d6a65f898..3cd1bc73d 100644 --- a/ai-company-brain/specs/skills_registry.md +++ b/ai-company-brain/specs/skills_registry.md @@ -264,3 +264,15 @@ CRM family, a personal instance doesn't) — lands with Centers Phase C; the WS-8 declarative manifest's `capabilities.skills` becomes the *declared* side of the intersection when agent_defs ship (same columns-now-manifest-later shape as decision D3). + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-23 — **Skills registry + per-agent skill toggles** (added 2026-08-01) +**State cell (as of the move):** 🟡 S1+S2 built +**Narrative (verbatim):** **S1+S2 shipped pending review 2026-08-01**. S1: `acb_skills/skill_families.py` registry + measured token-cost catalog, `GET /integrations/skills`, Integrations → Skills tab, drift test; measured baseline ≈19.3k tokens (core floor ≈15.1k). S2: `agent_skill_setting` table (override-shape provenance), `GET/PUT /agent/{name}/skills` (`admin:access:manage`; core/apps → 422), **intersection-only** enforcement in `_resolve_injected_scope` (no rows ⇒ byte-identical — regression-tested), Agents-page Skills panel with live token meter; decision note in spec §2: workflows toggle honored at its append site, Custom-App grants NOT toggle-governed. **S3 generation half + scope-out shipped pending review 2026-08-01**: addendum prose now GENERATED from family-tagged section registries in `acb_skills/addendum.py` (one renderer for injection AND catalog cost measurement; tool set byte-identical, text identical except the `App()Ellipsis` f-string fix); evidence-based scope-out in `specs/skills_scope_out.md` (GENERAL = core/memory/workflows/apps; SPECIALISED = history→orchestrator, coding→apis-config); `DEFAULT_PROFILE` + `SKILLS_FAIL_CLOSED` switch prepared and **shipped OFF**. Measured: all-families 19.3k → DEFAULT_PROFILE 17.8k → core floor 15.4k tokens — the ≤2k email target needs a core-floor diet, not toggles. Remaining: **OWNER-GATE** the `SKILLS_FAIL_CLOSED=1` flip (review dynamic agents first, `skills_scope_out.md` §4). Per-instance profiles defer to Centers C; manifest side lands with WS-8. **S4 core-floor diet BUILT 2026-08-01** (`skills_scope_out.md` §7): *Half A* `acb_skills/skill_index.py` — addendum becomes one line per family + `recall_notes("skills/.md")`, bodies materialized to `agent-data/skills/` content-hash-idempotently after the blob rehydrate, byte-preserved via the new `addendum.rendered_parts()`, index inside the prompt-cache-stable prefix, **`SKILLS_INDEX_ONLY` ships OFF**; *Half B* schema trim, live, **zero call-contract change** (pinned in `tests/unit/test_tool_schema_diet.py`). Measured: addendum 5,697 → **570**, core-floor schemas 9,998 → **8,510**, full surface 19,259 → **12,644**, email-assistant-recommended 17,757 → **11,337**. **≤2k still NOT met and unreachable by trimming** (22 schemas cost 1,252 tokens with descriptions deleted) — progressive tool disclosure + an `emit_generative_ui` schema pointer are designed and costed in `skills_scope_out.md` §7.5, **deliberately not built**. Remaining: **OWNER-GATE** the `SKILLS_INDEX_ONLY=1` flip. + +**Corrections applied 2026-08-09:** current as moved. diff --git a/ai-company-brain/specs/task_manager_app.md b/ai-company-brain/specs/task_manager_app.md index d5206e5df..9cfc38adc 100644 --- a/ai-company-brain/specs/task_manager_app.md +++ b/ai-company-brain/specs/task_manager_app.md @@ -1071,3 +1071,17 @@ wall-clock budget). Also this session: `web_search` is now SerpAPI-first - [ ] **Delegate & Monitor**: delegate a task to a teammate, see it on Waiting For, get an overdue flag, and get an agent-drafted follow-up nudge. - [ ] Assistant answers "what's my next action?", "what am I waiting on?", and "what's overdue across the team?" with citations to the PM tool. ``` + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-18 — **Tasks Phase 3** (Weekly Review, Waiting-For, ~~Horizons~~) +**State cell (as of the move):** 🟡 partial +**Narrative (verbatim):** **Audited 2026-08-02 → GO-NARROWED, and point 3 splits per view — the first row in four cycles to clear it.** ✅ **Waiting-For *surfacing* BUILT 2026-08-02, pending review** (`lib/waiting.ts` pure predicates + `WaitingForView.tsx` grouped by person + `ITEM_SELECT`/`GtdItemModel` now project the write-only mig-48 columns `expected_by`/`last_nudged_at`; **no migration — the substrate all shipped in mig 48**). Delegate now defaults `expected_by` from the item's own `due_at` (the in-app delegate path wrote NULL, so the headline §12 journey produced no flag at all). Fixed en route: a frozen `MOCK_NOW` (4 copies) that made the shipped overdue badge wrong by 33 days and growing, plus `mockData.ts`'s orphaned anchor. **🔴 Weekly Review = NO-GO** (§9.2 is a bare checkbox; `gtd_reviews.summary` is untyped JSONB — define the JSON contract + a per-movement done-when first). **🔴 Horizons = NO-GO and MIS-ASSIGNED** — no acceptance criterion exists anywhere, `gtd_horizons` has no link column to items/projects, and **the spec puts it in Phase 4, not 3**; strike it from this row's title or move it in the spec. **~~Open~~ CLOSED 2026-08-02 (follow-up):** `expected_by` now means exactly one thing — **an explicit human promise**. NULL ⇒ no promise was made, so the overdue line is the item's own `due_at` read **live** (nothing copied, nothing to go stale); non-NULL ⇒ a promise that stands independent of `due_at`. All four insert sites stopped deriving a copy (each was writing the item's own due date under another name), so the column is now written by exactly one path: `PATCH /tasks/items/{id}` with `expected_by` (ISO sets, `""` clears), which updates the open `gtd_waiting` row under a re-stated ownership `EXISTS`. Client judges `expectedBy ?? dueAt`. **No migration, no backfill** — rows delegated before this change keep their snapshot and stay judged on it; clearing one is a normal edit. **OWNER-GATE:** nudge drafting/sending (real-account email sends), delegation write-back to ClickUp (blocked on BO-1). **Drift found:** `gtd_reviews`/`gtd_horizons` have existed since mig 48 with zero gateway references — do NOT write a new migration for them; and the spec's `POST /tasks/projects/plan` was fiction (real: `POST /tasks/plan` + `/plan/apply`, shipped — only the ProjectPlanner UI is missing). **EVAL-LOCKED:** `propose()`/`propose_with_llm()` in `routes/tasks/ai.py`. + +**Corrections applied 2026-08-09:** +- current as moved +- coordinate any gtd_* schema work with WS-27h's retirement plan (D-PM-6 one-store). diff --git a/ai-company-brain/specs/task_manager_harness_2026-07.md b/ai-company-brain/specs/task_manager_harness_2026-07.md index 301551ccb..b10764c30 100644 --- a/ai-company-brain/specs/task_manager_harness_2026-07.md +++ b/ai-company-brain/specs/task_manager_harness_2026-07.md @@ -1,5 +1,7 @@ # Task Manager × Harness Engineering (2026-07-03) +> **Status:** Tier 1 shipped 2026-07-03 · Tier 2 planned, no board row (WS-18 owns task-manager work) · not verified against code since 2026-07-03. *(Header added 2026-08-09.)* + > **What this is.** The task-manager app reviewed against the practice areas in > [awesome-harness-engineering](https://github.com/ai-boost/awesome-harness-engineering), > as a companion to the platform-level [`core_module_map.md`](core_module_map.md) diff --git a/ai-company-brain/specs/tenancy_and_visibility.md b/ai-company-brain/specs/tenancy_and_visibility.md index b48a3ffe0..cda6ab039 100644 --- a/ai-company-brain/specs/tenancy_and_visibility.md +++ b/ai-company-brain/specs/tenancy_and_visibility.md @@ -1,6 +1,8 @@ # Tenancy and visibility — who can see what -**Status:** Architecture of record · owner-answered 2026-08-03 · **Date:** 2026-08-03 · +**Status:** Architecture of record for **visibility (§2–§5)**. ⚠️ **§1 and §6 (tenancy) were +re-taken on 2026-08-08 — see [`saas_multitenancy.md`](saas_multitenancy.md) §1.** · +owner-answered 2026-08-03 · **Date:** 2026-08-03 · **Verified against code:** 2026-08-03, **re-verified and corrected 2026-08-03** against `ws-14-doc-remediation` (parent `bebbd924`) · **Owner:** vjvarada @@ -47,9 +49,44 @@ owner for "who can see what" (`work_plan.md` §4). --- -## 1. DECISION — the tenant boundary is the deployment +## 1. DECISION — the tenant boundary is the deployment ⚠️ **SUPERSEDED 2026-08-08** -> ### `Tenant boundary = THE DEPLOYMENT.` *(owner-answered 2026-08-03)* +> ### ⛔ **RE-TAKEN. Read `saas_multitenancy.md` §1 instead.** *(owner-requested 2026-08-08)* +> +> **The reason: the business model changed.** CommandCenter is being sold to external +> customers, priced per module, per user, per month, plus metered AI. §1.4 of that +> document shows that price point and one-VM-per-customer are arithmetically +> incompatible, and §1.3 shows why the cost objection recorded in §1.2 below no longer +> holds: because `packages/acb_common/acb_common/db.py` is a **single** engine and a +> **single** `get_db()`, tenant scoping installs at one seam with Postgres RLS and +> **zero existing queries change** — the "a `WHERE organization_id = ?` on 111 tables" +> framing below was measured against an assumption, not against that seam. +> +> **The new decision:** *tenant = `organization_id`, enforced by Postgres RLS at the +> connection seam; the deployment is a placement (region/tier), not a tenant boundary.* +> A dedicated database or dedicated stack survives as a **priced enterprise tier**, which +> is what §1.2's cost analysis below is now the pricing input for. +> +> **§6 of this document is superseded with it** — row-level tenancy, an org switcher and +> multi-org users are now all in scope, per `saas_multitenancy.md` §1.5. +> +> **Everything else in this document survives unchanged and is still binding:** the +> visibility ladder (§3), the project-grant decision (§4), and the per-surface gap table +> (§5). Tenancy and visibility are different axes — tenancy is *which company*, visibility +> is *who inside that company* — and `saas_multitenancy.md` §7.8 restates §3.2's +> standing rule against a second scoping doctrine. +> +> ⚠️ **What un-mootedness costs.** §1.1 below concludes that leak sites 1–10 "cannot fire" +> because there is one `organization` row. Under the new decision **that premise is gone** +> and every one of them must be verified rather than assumed — +> `saas_multitenancy.md` §6.4 and §6.5 carry that list, and §6.1/§6.2 add two hard +> blockers (process-global credential injection; self-mutation writing to the shared +> monorepo) that must be fixed **before a second tenant exists at all**. +> +> The text below is retained verbatim as the record of the decision that was taken on +> 2026-08-03 and of why it was correct at the time. **Do not build against it.** + +> ### `Tenant boundary = THE DEPLOYMENT.` *(owner-answered 2026-08-03 · superseded 2026-08-08)* > > One deployment per tenant. If a second organization ever exists it gets its own > box, its own database, its own credential set. Row-level organization isolation @@ -93,10 +130,10 @@ org B. Verified samples, so a reader can judge the class: | 3 | `routes/admin/members.py:170-178` | invite is `INSERT … ON CONFLICT (email) DO UPDATE SET organization_id = EXCLUDED.organization_id` — under two orgs this is an account-takeover primitive | | 4 | `gateway/rooms.py:201-211` | the `in_org` check is `SELECT 1 FROM app_user WHERE email = :email AND COALESCE(status, 'active') = 'active'` (SQL at `:205-208`) — no org filter. *(Corrected 2026-08-03: the old citation `:184-190` and its `status='active'` quote were both wrong — `:184-190` is the `group_slugs` comprehension plus the head of the `my_groups` query, and the real predicate is `COALESCE`-wrapped.)* | | 5 | `gateway/rooms.py:384-393` | `SESSION_VISIBLE_SQL`'s `org`-participant arm — an `EXISTS` on a `subject = 'org'` row `AND` an `EXISTS` on an active `app_user`, same shape, same absence. The adjacent `s.visibility = 'org'` arm at `:394-400` has it too. *(Corrected 2026-08-03 from `:346-356`, which is the tail of `resolve_room_access`'s return — `is_shared`, `members`, `visibility` — and not SQL at all.)* | -| 6 | `acb_auth/access.py:338-340` | `_ORG_MEMBER_SQL` is `SELECT email FROM app_user WHERE status = 'active'` — the `org` subject expands to *every* active user on the box | +| 6 | `acb_auth/access.py:338-340` | `_ORG_MEMBER_SQL` is `SELECT email FROM app_user WHERE status = 'active'` — the `org` subject expands to *every* active user on the box *[Anchor stale: measured 2026-08-08 at access.py:400 / access.py:522 — re-derive with grep; see saas_multitenancy.md §6.4.]* | | 7 | `infra/postgres/130_org_access_control.sql:180` | role seeding does `SELECT id INTO org_id FROM organization WHERE slug = 'default'` (same in `131:` and `133:`) | | 8 | `acb_auth/access.py:439-458` | `_BOOTSTRAP_OWNER_SQL` hardcodes `slug = 'default'` | -| 9 | `acb_auth/access.py:460-464` | `_HAS_OWNER_SQL` is `SELECT 1 FROM user_role ur JOIN org_role r … r.slug='owner' LIMIT 1` — **no org filter**, so once *any* owner exists anywhere, `ensure_owner_bootstrap()` is a permanent no-op and a second org's users have no inviter | +| 9 | `acb_auth/access.py:460-464` | `_HAS_OWNER_SQL` is `SELECT 1 FROM user_role ur JOIN org_role r … r.slug='owner' LIMIT 1` — **no org filter**, so once *any* owner exists anywhere, `ensure_owner_bootstrap()` is a permanent no-op and a second org's users have no inviter *[Anchor stale: measured 2026-08-08 at access.py:400 / access.py:522 — re-derive with grep; see saas_multitenancy.md §6.4.]* | | 10 | every app-data table | `gtd_items`, `email_*`, `meeting`, `apps`, `workflows`, `chat_session`, `agent_blob`, `mem` carry no org column at all — the bulk of the surface | Under one deployment per tenant, **sites 1, 2, 3, 4, 5, 6, 7, 8, 10 cannot fire** — @@ -163,7 +200,9 @@ retiring that constraint. *within* an organization — `UNIQUE (organization_id, slug)` (`138_groups_and_session_participants.sql:49`) — so a slug-only join is a cross-organization match by construction. Under §1 there is one org today, so -nothing leaks today. They are on this list for two reasons: they are three +nothing leaks today. *[2026-08-09: premise retired by D15 — with a second +organization these three joins leak across tenants, which is why WS-29 absorbed +TV-1 as MT-1i. The done-whens below stand verbatim.]* They are on this list for two reasons: they are three one-line predicates now and an archaeology project later, and **two of the three sit inside the session-authority intersection**, which is the single most consequential access computation in the codebase (`groups_sessions_authority.md` @@ -350,7 +389,9 @@ mistaken for an oversight):** DESC` (`crud.py:91`) with no owner predicate, and delete is `DELETE FROM workflows WHERE id = :id` (`:346`). Anyone holding `feature:workflows` sees and can delete every workflow. That is fine for an internal tool with one org; it is the first - thing that must change if a Center wants a private automation. + thing that must change if a Center wants a private automation. *[2026-08-09: + under D15/WS-29 this is now scheduled work, not an accepted posture — see + saas_multitenancy.md §2 (entitlements) and MT-1b.]* - **Memory `org:global`** — org-wide by definition. **A new surface must declare its tier.** This is the doctrine that stops each new @@ -520,7 +561,20 @@ this conversion adds `group:` **only** and must leave `org` rejected. --- -## 6. Explicitly out of scope +## 6. Explicitly out of scope ⚠️ **SUPERSEDED 2026-08-08 — all four items are now IN scope** + +> ⛔ **Re-taken by `saas_multitenancy.md` §1** (owner-requested 2026-08-08), by exactly the +> procedure the closing line of this section prescribes. Items 1–4 below are now queued +> work, not prohibitions: +> +> | Was out of scope | Now | +> |---|---| +> | 1. Row-level multi-tenancy | **The mechanism.** `organization_id` + `FORCE ROW LEVEL SECURITY` on every table, bound at the `get_db()` seam with `SET LOCAL app.tenant_id` — `saas_multitenancy.md` §1.3 | +> | 2. An org switcher | **Subdomain-resolved tenant**, bound to the authenticated session. Never a client-settable header — §1.5 | +> | 3. Users belonging to multiple orgs | **Supported**, via a global `user_identity` + `org_membership` split; RLS is what makes it cheap — §1.5 | +> | 4. Per-org credentials inside one deployment | **Required.** `provider_keys` becomes `(organization_id, provider)` — §6.3, and it is a *blocker*, not a nice-to-have | +> +> The text below is retained as the record of what was decided on 2026-08-03. Named so nobody builds them, and so a future audit does not re-file them as gaps: diff --git a/ai-company-brain/specs/user_management_contract.md b/ai-company-brain/specs/user_management_contract.md index d95050cbd..f82c8b620 100644 --- a/ai-company-brain/specs/user_management_contract.md +++ b/ai-company-brain/specs/user_management_contract.md @@ -2,7 +2,9 @@ **Status:** 🟢 Binding · **Created:** 2026-08-05 · **Owner:** vjvarada · **Board row:** WS-24 · **Verified against code:** 2026-08-05, on `main` @ `74082882` -(deployed and running). +(deployed and running). · **Amended 2026-08-08:** **R11** (never take the acting tenant +from input) added with D15/WS-29; the rule count is now **eleven**, and the fact-owner +table below splits tenancy from visibility because D11 was re-taken. > **This document owns RULES, not FACTS.** Every fact it states is owned by > another spec and is cited, never restated as though this were its home: @@ -11,7 +13,9 @@ > |---|---| > | The access model — roles, permissions, overrides, resolution | `org_access_control.md` | > | The readiness gate, the onboarding runbook, the role × app matrix | `colleague_onboarding.md` | -> | The tenancy boundary and the visibility ladder (D11/D12) | `tenancy_and_visibility.md` | +> | The **visibility ladder** inside a tenant (D12) | `tenancy_and_visibility.md` §3–§5 | +> | The **tenancy boundary** (D15 — re-taken 2026-08-08; D11 is superseded) | `saas_multitenancy.md` §1 | +> | Multi-tenancy build shapes — SQL, seams, ratchets, runbooks | `saas_multitenancy_implementation.md` | > | Centers as projections | `department_centers.md` | > > If this doc and an owner disagree, **the owner is right and this doc is @@ -178,6 +182,29 @@ hide the button. casing between sessions must not silently switch a guard off or empty somebody's library. +**R11 — Never take the acting TENANT from input.** *(Added 2026-08-08 with D15; +this is R-identity's twin and was created by the same reasoning.)* The +organization a request acts in comes from the **authenticated session** or from a +**tenant-scoped API key**, and from nowhere else. Not an `X-Organization-Id` +header, not a query parameter, not a body field, not a subdomain the server +trusts without re-resolving it against the session. + +R3 says never take the acting *identity* from a query parameter; under D15 the +tenant is the wider blast radius of the same mistake — an identity you can spoof +gets you one person's data, a tenant you can spoof gets you a whole company's. +`multi_user_organization_research.md` §17.3 proposes exactly this header, and +`saas_multitenancy.md` §7 item 2 **rejects it by name** so a future reader does +not implement the research. + +Two corollaries an implementer must not shave: + +- **A background job carries its tenant on its job record** and refuses to run + without one (`saas_multitenancy.md` MT-1d). A job with no request has no session + to inherit from, and a job that guesses leaks unbounded rather than one row. +- **The subdomain is a lookup, not an assertion.** Resolve `` to an + organization, then verify the authenticated principal holds a membership in it. + A subdomain that is trusted on its own is a header with a friendlier name. + --- ## 5. The traps, with the incident that found each diff --git a/ai-company-brain/specs/whatsapp_message_manager.md b/ai-company-brain/specs/whatsapp_message_manager.md index 8719bd28d..cd333d0f2 100644 --- a/ai-company-brain/specs/whatsapp_message_manager.md +++ b/ai-company-brain/specs/whatsapp_message_manager.md @@ -8,7 +8,7 @@ > `apps/services/gateway/gateway/routes/whatsapp/`, migrations 102–111, 227 backend unit > tests; activation gated on env config (`WHATSAPP_ENRICHMENT`, `WHATSAPP_APP_SECRET`, > `WHATSAPP_PUBLIC_URL`, …) and Meta app review (Embedded Signup). §1–§10 are the design -> record; §11 is the build log and current state. +> record; §11 is the build log and current state. · sibling surface: `whatsapp_calls_note_taker.md` Surface C (calls + recording) SHIPPED 2026-08-02 on this stack *(cross-ref added 2026-08-09)* > *(Update 2026-08-01, doc-truth pass: header previously said "PLANNING — no code yet", > contradicting §11's own build log; verified against the repo.)* > **Mockups:** `mockups/whatsapp_message_manager.html` (7 screens + build notes, control-plane shell, diff --git a/ai-company-brain/specs/workflows_app.md b/ai-company-brain/specs/workflows_app.md index 5519a43d0..7e65650bf 100644 --- a/ai-company-brain/specs/workflows_app.md +++ b/ai-company-brain/specs/workflows_app.md @@ -2,7 +2,7 @@ > **Product:** CommandCenter · **Feature:** Workflows app (`/workflows`) · **Updated:** 2026-08-03 · **Version:** 0.3 · **verified against code on 2026-08-03** > **Status:** 🔄 Slices 1+2 built — data model (migration 132) + gateway API + MAF compiler/engine + `/workflows` visual editor + Module Studio + **Workflow Copilot (F14)** + **keyword capability search (F15; semantic → BO‑22)** + **event triggers (F10)** + **approval node with pause/resume via the Action Broker inbox (F11)** + **workflows as agent tools (F13)** + **run-history drill-in (F9 complete: a history row replays its recorded node results onto the canvas)** + **F1/F6 complete (gallery search + duplicate/delete, version rollback via the status-badge popover)** + **F3's logic vocabulary complete (wait node — inline under a minute, durable pause above it; approval and wait now both in the catalog/palette)**. All five trigger kinds live: manual, api, webhook, schedule, event. Engine semantics are locked by a golden trajectory eval (`evals/trajectories/test_workflow_engine_trajectory.py`, CI-blocking); orphaned `running` rows are swept to `failed` at gateway startup (paused runs survive — resume rebuilds from the pause snapshot). **R2 is mitigated (migration 134):** a published workflow whose unattended runs fail `AUTO_DISABLE_AFTER` times consecutively disables itself with a recorded reason, and `POST /{id}/enable` is the one-click way back. -> **Slice 3 re-scoped 2026-08-03 (truth pass, §8.3).** The one-line Slice 3 asked for three things and **one of them is already shipped**: describe→generate→refine full-graph authoring landed as F14 (`39b1e17a`) and is **struck**. "Parallel fan-out" is also shipped (`engine/graph.py:17`; MAF's superstep scheduler routes it) — the unbuilt half is **fan-in/join**, restated as such. What genuinely remains is **fan-in/join (8.3b), loops (8.3c), and a template gallery (8.3a — nothing exists)**. Two owner decisions recorded the same day: Command Center is an **internal Fracktal tool** (§1.4) and **loops are approved** despite §11 R1 (§8.3c). +> **Slice 3 re-scoped 2026-08-03 (truth pass, §8.3).** The one-line Slice 3 asked for three things and **one of them is already shipped**: describe→generate→refine full-graph authoring landed as F14 (`39b1e17a`) and is **struck**. "Parallel fan-out" is also shipped (`engine/graph.py:17`; MAF's superstep scheduler routes it) — the unbuilt half is **fan-in/join**, restated as such. What genuinely remains is **fan-in/join (8.3b), loops (8.3c), and a template gallery (8.3a — nothing exists)**. Two owner decisions recorded the same day: Command Center is an **internal Fracktal tool** (§1.4) (re-scoped 2026-08-08, see §1.4 note) and **loops are approved** despite §11 R1 (§8.3c). > **Parent RFC:** [`docs/workflow-editor/README.md`](../../docs/workflow-editor/README.md) — stack selection (React Flow), the compile-to-MAF-Workflows decision, data model, editor UX, trigger taxonomy. Read it for *how*; this doc is *what, why, and why now*. Interactive mockup: `docs/workflow-editor/mockup.html`. > **Reference precedents:** [`task_manager_app.md`](task_manager_app.md) (app spec shape) · [`docs/app-workshop/README.md`](../../docs/app-workshop/README.md) §4.0 (the platform contract this app also enforces). > **Engine uplift backlog — §13 (added 2026-08-06).** A code-verified read of Paca's automation engine ([`paca_pm_research_2026-08.md`](paca_pm_research_2026-08.md) §4–§6) against this engine, as eight scoped items **U1–U8** with done-whens, plus the `pm.*` binding that already ships and the five Paca features deliberately refused. §13 is **backlog, not built work**; it does not change Slice 3 (§8.3) or Slice 4 (§8.4). @@ -53,10 +53,10 @@ The missing quadrant is **deterministic + self-serve**: the ops owner defines th - **Not an agent editor.** No editing of `agents.py`, instructions, or skills from the canvas — ADR-014's authoring rule stands for *code* artifacts. - **Not a second runtime.** No n8n, no LangGraph, no embedded workflow engine — the graph compiles to MAF Workflows (ADR-028). If MAF can't express something, the platform grows; the app never routes around it. - **Not a general-purpose code platform.** Modules are sandboxed, dependency-free, pure-transform Python; anything bigger belongs in a skill repo via PR (and Module Studio says so — the "builder refuses and redirects" rung of the platform contract ladder). -- **Not multi-tenant marketplace tooling.** Workflows are org-internal; sharing/templates beyond this org are Phase 4+. *(Clarification 2026-08-01: the org-internal template gallery is Slice 3 — this non-goal refers to cross-org marketplace sharing, not in-org templates.)* +- **Not multi-tenant marketplace tooling.** Workflows are org-internal; sharing/templates beyond this org are Phase 4+. *(Clarification 2026-08-01: the org-internal template gallery is Slice 3 — this non-goal refers to cross-org marketplace sharing, not in-org templates.)* *(unchanged for now; revisit at MT-2 — `saas_multitenancy.md` §2)* - **No autonomous outward writes.** Same rule as everywhere else: write-class integration actions require the approval node / Action Broker disposition until BO‑1 lands fully. -**OWNER DECISION 2026-08-03 — Command Center is an internal Fracktal tool.** The team uses it; there are no external tenants and none are planned in this app's horizon. Scope is weighed accordingly: features whose only justification is *someone else's org* (template marketplaces, per-tenant template stores, sharing permissions on content) are out, and "one org, engineers in the room, ships with the code" is a legitimate answer to a storage or distribution question — see the §8.3a decision, which is decided on exactly that basis. This does **not** relax the platform contract (§3.2), the approval gates (G4), or capability checks (Q3): internal does not mean unguarded, it means un-multi-tenanted. +**OWNER DECISION 2026-08-03 — Command Center is an internal Fracktal tool.** The team uses it; there are no external tenants and none are planned in this app's horizon. Scope is weighed accordingly: features whose only justification is *someone else's org* (template marketplaces, per-tenant template stores, sharing permissions on content) are out, and "one org, engineers in the room, ships with the code" is a legitimate answer to a storage or distribution question — see the §8.3a decision, which is decided on exactly that basis. This does **not** relax the platform contract (§3.2), the approval gates (G4), or capability checks (Q3): internal does not mean unguarded, it means un-multi-tenanted. *[Premise re-scoped 2026-08-08 (D15, WS-29): external tenants are now planned — this decision's storage/distribution answers stand for the internal org today and must be re-visited at MT-2 (module entitlements); 'none are planned' is struck.]* --- @@ -532,3 +532,20 @@ Not oversights — decisions, so a future reader does not "fix" them: ### 13.5 Where the numbers are Effort-shaped grouping for whoever picks this up, so the section can be sequenced without re-reading it: **U1 is WS-27f's first half and the only item anything is waiting on. U7 is its second half.** U2 and U3 are self-contained engine work with no cross-app dependency — U3 is the one that closes a promise §1.2 G6 and §2 F9 already make in writing, which arguably ranks it first of the two. U4 waits on U1 by construction. U5 waits on §8.3b by decision. U6 is independent and is the highest-value *new trigger*. U8 is guidance that binds the next tool surface and consumes no ticket. + +--- + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-11 — **Workflows Slice 3** (template gallery, fan-in/join, loops); Slice 4 after WS-4 + +**State cell (as of the move):** 🟢 + +**Narrative (verbatim):** Slice 3 = **8.3a** template gallery · **8.3b** fan-in/join · **8.3c** loops (**owner-approved 2026-08-03**, D10 — §11's standing anti-n8n rule R1 governs the node *catalog*, not the control-flow *vocabulary*, and must not be cited as a blocker on loops). All three AGENT-SAFE. **~1/3 of this row was struck:** "describe→generate→refine full-graph authoring" **shipped as F14** (`39b1e17a`) — dispatching it would have sent an implementer to rebuild the live `POST /workflows/{id}/copilot`; "parallel fan-out" also ships (`engine/graph.py:17`, MAF's superstep scheduler routes it), so the real remaining content is fan-**in** plus loops. Templates are greenfield — nothing exists. **8.3b and 8.3c each invert a pinned test** (`test_fan_in_rejected_v1`, `tests/unit/test_workflows_engine.py:155`; `test_cycle_rejected`, `:148`) — leave either asserting rejection and the ticket closes **green having built nothing**. Template *content* is an owner input; the report-digest template is **WS-15's** artifact, not this row's. Slice 4 stays blocked on **BO-20b slice 2 → BO-20c → (BO-20d, BO-20e) + BO-7** (§8.4), and its activation rides the OWNER-GATE `INGESTION_CONSUMER` flip. + +**Corrections applied 2026-08-09:** +- Slice 4's bare "BO-7" dependency is restated: sandbox-dependent parts follow MT-0c-2's trigger (D16); the queue chain (BO-20b2→c→d,e) and the `INGESTION_CONSUMER` flip are the operative blockers. diff --git a/ai-company-brain/system_architecture.md b/ai-company-brain/system_architecture.md index 43de0601a..44b3b6894 100644 --- a/ai-company-brain/system_architecture.md +++ b/ai-company-brain/system_architecture.md @@ -1,7 +1,7 @@ # System Architecture — CommandCenter v2 (Distributed, Self-Mutating Agent Network) > Project: CommandCenter v2 · Org: Fracktal Works · Date: 2026-06-02 -> Updated: 2026-06-10 — (v2.5) Unified MAF runtime: Copilot SDK agents now run through CommandCenterCopilotAgent (MAF subclass). Package upgrades: agent-framework-core 1.8.0, agent-framework-github-copilot 1.0.0rc1, github-copilot-sdk 1.0.0. Local git tracking for pure MAF agents. Mutation layer enhanced with agent purpose context. +> Updated: 2026-06-10 — (v2.5) Unified MAF runtime: Copilot SDK agents now run through CommandCenterCopilotAgent (MAF subclass). Package upgrades: agent-framework-core 1.8.0, agent-framework-github-copilot 1.0.0rc1, github-copilot-sdk 1.0.0. Local git tracking for pure MAF agents. Mutation layer enhanced with agent purpose context. · ⚠️ **stale-warning added 2026-08-09**: body last verified ~2026-06-10 and predates D15 multi-tenancy, the workflows app, CRM/Projects/People apps — trust `specs/` and `work_plan.md` over this file where they disagree; re-verify any anchor before use > Status: v2.5 — Single unified MAF runtime. All agents MAF-native. Local git tracking for agent folders. --- @@ -873,7 +873,7 @@ Memory is **best-effort** — if `MEM0_API_URL` is not set, all endpoints return ### ADR-027: Agent state (files + memory) is Postgres-authoritative with a disposable disk cache — **implemented (2026-07-15)** - **Context:** An agent's *code* is git-backed and reviewed, but its *accumulated state* — the files it writes and the knowledge it builds up (the three folders `agent-data/`, `inputs/`, `outputs/`) — lived only on the on-disk clone at `{agents_clone_dir}/repos/{agent}`. Deploy does `git reset --hard` and the loader re-syncs source over the clone, so uncommitted state was wiped on every redeploy / box migration. Conflating "code" and "state" was the recurring bug. -- **Decision:** Two axes of durability. **Code → git** (monorepo PR for native MAF, own repo for Copilot agents), human-reviewed. **State → a Postgres blob store** (`agent_blob` current content + append-only `agent_file_history` versions, migration 71), authoritative; the disk workspace is a rehydratable cache. Same model as Mem0. Keyed on `agent_name` only → portable to a second tenant deployment unchanged. Mechanism: **write-through** at every write path (agent `write_artifact`/`save_note`; gateway save/upload/delete/promote), **rehydrate on load** (executor, before each run), **fault-in on read miss** (gateway file endpoints). Read paths (file manager / chat / artifacts apps) are unchanged — the store sits behind them. Memory has three Mem0 scopes (user / agent-cross-user / org-global). +- **Decision:** Two axes of durability. **Code → git** (monorepo PR for native MAF, own repo for Copilot agents), human-reviewed. **State → a Postgres blob store** (`agent_blob` current content + append-only `agent_file_history` versions, migration 71), authoritative; the disk workspace is a rehydratable cache. Same model as Mem0. Keyed on `agent_name` only → portable to a second tenant deployment unchanged. *[2026-08-09: contradicted by `specs/agent_persistence_implementation.md` — agent_name alone is no longer a sufficient key; and under D15 portability means per-organization scoping (MT-1b/MT-1g), not a second deployment.]* Mechanism: **write-through** at every write path (agent `write_artifact`/`save_note`; gateway save/upload/delete/promote), **rehydrate on load** (executor, before each run), **fault-in on read miss** (gateway file endpoints). Read paths (file manager / chat / artifacts apps) are unchanged — the store sits behind them. Memory has three Mem0 scopes (user / agent-cross-user / org-global). - **Consequences:** Agent files + memory survive redeploy, volume wipe, and box migration. The six-function async store API (`put_file`/`get_file`/`list_files`/`delete_file`/`file_history`/`rehydrate_workspace`) is the seam — a future move to an object store swaps the sync core, nothing upstream changes. Graceful degradation: DB down → store calls no-op, agents keep working off disk. **Open production question:** the *code*-mutation half (native-MAF → shared-monorepo PR) is DEV-ONLY and must become tenant-isolated before multi-tenant/customer use (ADR-006, ADR-021, and `docs/DESIGN_LIMITATION_native_maf_mutation.md`). Full engineering detail: `specs/agent_persistence_implementation.md`; contract: `specs/agent_file_and_memory_framework.md`. --- diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index dea849d8c..6b8456d7d 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -1,6 +1,13 @@ # Work Plan of Record — the dispatch board -**Status:** Active · **Date:** 2026-08-03 (six-row truth pass: WS-1, WS-3, WS-8, +**Status:** Active · **Date:** 2026-08-09 — **multi-tenancy consolidation pass** (§5 +residual 7 is the change list): the D11/D10-premise purge across the corpus after +D15/D16, §2 compacted per **D18** with row narratives moved to owning specs' "Board +record (2026-08-09)" sections, **R5** (tenant-ready by construction) minted, **D17** +(Mem0 binding) + **D18** (priority of record · board format · MT-2/3 pricing inputs) +recorded, WS-29 updated with the H1 scratch-verify result and PR #404, and eighteen +stale-vs-merged row claims swept (branch protection, ledger, backups, deploys, +WS-13/26/27 states). **Prior pass 2026-08-03** (six-row truth pass: WS-1, WS-3, WS-8, WS-11, WS-12, WS-21 swept to match their rewritten specs; D10 records two owner calls. **Second pass the same day:** D11 + D12 record the tenancy boundary and the visibility model from `specs/tenancy_and_visibility.md`; WS-14 unblocked; @@ -73,6 +80,20 @@ verification commands.) Center/module/group as defined there. - **R4 — status changes propagate.** A PR that ships spec'd work updates the owning spec's status header in the same PR. +- **R5 — tenant-ready by construction** *(owner-directed 2026-08-09, D18; binds + every PR while WS-29 is in flight)*. App work continues in parallel with the + tenancy retrofit on these terms, each enforced by an existing test, not by + prose: **(a)** every new persisted table is tenant-scoped — it must satisfy + `tests/unit/test_tenant_coverage.py`'s source gate (covered by the generated + RLS migration, or in `gen_tenant_migration.EXEMPT` with a reason a reviewer is + expected to challenge); **(b)** no new database connection sites outside the + seam — additions to `_SYNC_ENGINE_ALLOWED` / `_PSYCOPG_ALLOWED` need a cited + reason in the PR; **(c)** new Redis keys go through the tenant-prefix wrapper + (allow-list additions likewise); **(d)** session acquisition uses the current + seam idiom only, so H2's conversion stays mechanical — do not invent new + acquisition idioms; **(e)** never trust a tenant (or identity) from request + input — `user_management_contract.md` R11/R3. The ratchet tests ride PR #404; + until it merges they bind on the WS-29 branch, from merge they bind `main`. --- @@ -98,69 +119,147 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`. | # | Exception | Where it lives | Why it can't wait for "after the apps" | |---|---|---|---| | 1 | ~~**`main` has no branch protection**~~ — **CLOSED 2026-08-03** | WS-5 · checklist §BO-17 | Was `404 Branch not protected` with rulesets `[]` under both mechanisms, so every CI gate in the YAMLs was decorative. **Enabled 2026-08-03** (owner-authorised in-session): PRs required, `required_approving_review_count: 0`, **`enforce_admins: true`**, force-push and deletion blocked. Verified by reading the protection back. ⚠️ **`required_status_checks` is deliberately `null`**: `pr-check.yml` has `paths-ignore: ["**.md", "ai-company-brain/**"]`, so a docs-only PR produces **no** check-runs — requiring those contexts would make every docs PR permanently unmergeable (this row's own PR included). Tightening path: add an always-runs sentinel job to `pr-check`, then require **that** one context. | -| 2 | **No backup / restore path** | **new: checklist §BO-23** | The only DB script that dumps anything is `scripts/dump_schema.sh`, which is `pg_dump --schema-only` (`:52`) — **structure, zero rows**. There is no `pg_restore`, no logical data dump, no WAL archiving (`archive_mode`/`wal_level`/`pgbackrest`/`wal-g` appear nowhere in `infra/` or `deploy/`), and no restore runbook. Meanwhile `scripts/apply_migrations.sh` replays **every** numbered migration ≥ `02_` on **every** deploy under `psql -v ON_ERROR_STOP=1` (`:59-74`) with no ledger and no down-migrations — 140 files today, 142 numbered files on disk. `deploy/hostinger/README.md:115` is honest that the only backup is Hostinger's **weekly whole-VPS** image and that PITR is a "later" item. Largest uncovered risk, and it scales with app count. | +| 2 | ~~**No backup / restore path**~~ — **CLOSED in substance 2026-08-07** | checklist §BO-23 | Nightly `acb-backup.timer` **verified scheduled 2026-08-07** (after three same-day defects: #382 wrong script, #383 fork bomb, #384 mig-148 cast); a restore was rehearsed for real 2026-08-05 (`live=228 restored=228`); the migration **ledger is merged** (`5f025d80`, renumbered to 153), so a deploy stops replaying the whole ladder. Residue: `BACKUP_REMOTE` unset — off-box copy **deferred by owner decision 2026-08-05** (`backup_and_restore.md` §4.2); losing the disk, box or provider account still falls back to the weekly two-deep Hostinger image. Verify backups by deploy-log lines, never job conclusion. | | 3 | ~~**DB engine sprawl**~~ **CLOSED 2026-08-06** | checklist §BO-10 | Measured 2026-08-03: **12 `create_async_engine(...)` call sites across 10 modules** (`acb_auth/access.py:69`; gateway `routes/{admin,apps,email,notes,tasks,whatsapp,workflows}/*core*.py`; `email_ingestion/{inbound,scheduler}.py` ×4), plus a 13th **sync** `create_engine` in `acb_graph/db.py:32`. Eight are module-level cached `_ENGINE` singletons and **none of them is disposed on shutdown** — the only `engine.dispose()` calls in the tree are the four `email_ingestion` per-call engines cleaning up after themselves. **This is the one that compounds: one engine per app, added by each app.** The next app should extend a shared seam, not add engine 13. **CLOSED 2026-08-06:** every async caller now resolves to ONE engine and pool in `packages/acb_common/acb_common/db.py` — not `acb_graph` (the gateway does not depend on it, and its engine is sync) and not `gateway/db.py` (which `acb_auth/access.py` cannot import, so a gateway-owned seam could never get below two pools in the gateway process). The six remaining route packages plus `acb_auth.access` were converted, each keeping its historical `get_db`/`_get_db`/`_get_session_factory` name as a re-export so ~50 call sites and every test monkeypatch are untouched; `gateway/db.py` is a re-export. `acb_auth`'s engine had never carried the 2026-08-06 connect/`idle in transaction` bounds — it does now. Pool ceiling 30 (tunable via `db_pool_size`/`db_max_overflow`), deliberately not the old ~165 sum, which exceeded a stock `max_connections` of 100 shared with Langfuse/LiteLLM/ingestion. `acb_audit.record()` is non-blocking on the loop (`to_thread` only when a loop is running; sync callers still inline) and `acb_audit.drain()` is awaited last in the gateway lifespan. Guarded by `tests/unit/test_db_engine_seam.py` + `tests/unit/test_audit_non_blocking.py`. Still open by design: `acb_graph/db.py`'s **sync** `create_engine` and `email_ingestion`'s per-run engines. | +**Row discipline (D18, 2026-08-09).** Rows below carry state, gates and pointers — +nothing else. The narrative that used to live in these cells (up to 29.5k characters +per row; §2 alone was ~77k tokens, unreadable in one pass by the dispatch loop it +serves) was moved verbatim into each owning spec's **"Board record (2026-08-09)"** +section, with that day's corrections applied and enumerated there. R4 binds a +shipping PR to update the row *and* the owning spec's header; **R5 (§1) binds every +PR to tenant-ready-by-construction while WS-29 is in flight.** Git history and the +owning specs are the archive; this file owns ordering, gates and states only. + ### Substrate (foundation) -| WS | Workstream | Owning spec | State | Next / notes | +| WS | Workstream | State | Owning spec · record | Gates · next (verified) | |---|---|---|---|---| -| WS-1 | **Action Broker truth + completion** (BO-1) | `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-1 (rewritten + verified against code 2026-08-03) | 🟢 | Broker loop LIVE and writing (inbox, `/actions`, ClickUp + WhatsApp + workflow + app-publish handlers). **Handlers register at SIX sites, not the three this row claimed** (five measured 2026-08-03, plus the CRM's on 2026-08-05): `gateway/main.py` registers the four ClickUp task actions, `workflow.resume_run`, and — new — the three `crm.zoho_*` sync pushes; `routes/whatsapp/scheduler_hooks.py` registers `whatsapp.broadcast`; and `routes/apps/tools.py` registers two app-tool actions **at module import**, not startup. ~~"Remaining: **Zoho** handlers"~~ **struck as BO-1 work and it stays struck — the Zoho handlers now exist and are WS-26b's, not this workstream's.** `apps/services/ingestion/ingestion/sources/zoho/client.py` **stays** read-only — as of 2026-08-07 it is TEN read functions (WS-26b added `list_leads` and the deleted-records reader `list_deleted`; WS-26f added `list_deal_layouts` and `list_deal_pipelines`), all `GET`, and its one `POST` is still the OAuth token refresh. ⚠️ The claim "still all `GET /crm/v2/*`" is no longer true and must not be restored: WS-26f's two settings readers are the one deliberate exception (`settings/pipeline` does not exist on v2), version named once as `client.SETTINGS_API_VERSION` with a refusal reported rather than retried downward. Line numbers deliberately dropped: they drifted the first time anybody touched the file. ~~"There is no Zoho write path anywhere in the repo to route through the broker"~~ **corrected 2026-08-05 — there is one now, and it is NOT BO-1's.** WS-26b built `apps/services/ingestion/ingestion/sources/zoho/writer.py` (create/update/upsert/delete) on branch `ws-26b-zoho-sync`, per spec `crm_app.md` D-CRM-7/D-CRM-8. It has exactly ONE caller — `gateway/routes/crm/sync_zoho.py::execute_push`, grep-asserted in `tests/unit/test_crm_zoho_sync.py` — and every push crosses `routes/crm/broker_handlers.py::broker_gate` first. Its three actions (`crm.zoho_create`/`_update`/`_delete`) are registered from `main.py` alongside the ClickUp set, so the handler-registration count is now SIX sites, not five, and **all three CRM actions have handlers** (BO-1a's gap is ClickUp-only). Nothing has run against the tenant: `CRM_ZOHO_SYNC` ships OFF and enabling it is OWNER-GATE §6. The whole write path retires with WS-26e. ~~"verify vs live DB"~~ → **OWNER-GATE, and the "already done 2026-07-13" claim is UNSUPPORTED** — `FOUNDATION_CONTINUATION.md:145` records it outstanding and nothing since records it executed; no agent may claim it done or reach prod to do it, and it is not an acceptance criterion for anything below. **Three new tickets in §BO-1, all AGENT-SAFE, one PR each — the first two are flip-blockers, both new findings:** **BO-1a** — `providers.py` routes **six** ClickUp action names through `_broker_gate` but `broker_handlers._WRITERS` registers **four**, and the two missing are the two *irreversible* ones (`clickup.delete_task` `:551`, `clickup.archive_task` `:575`); under enforcement, approving one falls into `broker.execute()`'s no-handler branch (`broker.py:155-166`) and the row is marked **`failed`**. **BO-1b** — `_broker_gate` returns `{"pending": True, …, "provider_task_id": ""}` (`providers.py:171-172`) and `items._push_pending_item` ignores the marker, writing `sync_state='synced'` with an empty `provider_task_id` — under enforcement the user sees a green "synced" task that exists in no workspace. **BO-1c** — email handlers (zero `action_broker` wiring under `email_ingestion/`), buildable but blocked on §BO-1's recorded decision naming which of the base class's **14** mutating verbs are broker actions. **OWNER-GATE:** flipping `ACTION_BROKER_ENFORCE` on — **not until BO-1a and BO-1b are both in**, for the two reasons above. | -| WS-2 | **Secrets** (BO-8: rotate Zoho token, purge history, fail-closed) | checklist §BO-8 + `FOUNDATION_CONTINUATION.md` | 🔴 | **OWNER-GATE end-to-end** (force-push, rotation). Standing P0 since 2026-07-11. | -| WS-3 | **Isolation ladder** (BO-7 / HH-6 — T0/T1/T2 per `agent_platform_hardening_2026-07.md` §1.2) | `permissions_sandbox_b6.md` | 🟢 **WS-3a** (record + refuse, §P5-a.2) · 🟢 **WS-3b** (rootfs + network posture, §P5-b.2) | P5-a (per-run credential scoping, 2026-07-04) + P5-b.1 (cap/resource ceilings, 2026-07-27) shipped. **T2 / P5-c PARKED** under the internal-tool threat model (owner decision 2026-08-03, D10) — the ladder must hold against trusted colleagues, not hostile users; **un-parking is OWNER-GATE**, and no acceptance should be written for P5-c until it happens. P5-d is blocked behind it. **Two claims struck from the old title:** `tool_scope` deny belongs to **WS-23** (shipped there), and "T2 for non-first-party agents" named a distinction the code does not carry — no `first_party` field exists on any manifest, config or column; the phrase occurs only in comments and one test helper. **OWNER-GATE:** the `AGENT_PERMISSION_MODE` enforcement flip · P5-b.3's scoped gateway key (unbuilt *and* undesigned) · the new `ISOLATION_TIER_ENFORCE` flip WS-3a introduces. | -| WS-4 | **Event-bus consumer + durable queue** (BO-20) | `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-20 — **the file is at the REPO ROOT, not under `ai-company-brain/`** (this row's old anchor was wrong) | 🟢 a+f built · b slice 1 built · b slice 2 + c–e open | **§BO-20.0 IS ANSWERED — `BO-20 = Option A (in-process)`, owner, 2026-08-02.** Nothing in this row is blocked on a decision any more; the recorded rejection of Option B (a separate `python -m ingestion.worker`: needs a systemd unit no agent can deploy, and a separate process starts with an empty `event_hooks._SINKS`, so it would `XREADGROUP`, `XACK` and dispatch to nothing) is kept in §BO-20.0 as the reasoning, not deleted. **BO-20a BUILT 2026-08-02, pending review:** `apps/services/ingestion/ingestion/consumer.py` — `XGROUP CREATE cc-ingest $ MKSTREAM` on all three streams (`$` = tail, so the ~10k buffered entries per stream are skipped, not replayed into real workflow runs), a supervised `XREADGROUP` drain loop (`_GROUP="cc-ingest"`, `_BLOCK_MS=5_000`, `_READ_COUNT=8`, per-worker consumer name `gw--` because BO-20b's `XAUTOCLAIM` identifies a dead worker by it) decoding `{event_type, JSON data}` into `event_hooks.emit_event(source, event_type, dict)` and `XACK`ing, a long-lived pooled `redis.asyncio` client per `acb_common/activity.py:66-76`, `start/stop_ingestion_consumer()` + `consumer_status()` in the gateway lifespan (start `main.py:307`, stop `:364` — **unconditional**, like `stop_whatsapp_enrichment`), and the **§BO-20 Q1 cutover in all three receivers**: flag ON ⇒ enqueue-only, flag OFF ⇒ **dispatch-identical** to before (not byte-identical — each receiver now also does one function-body import + one `os.environ` read per request). Packaging defect closed: `ingestion` is now a declared gateway dependency (`pyproject.toml` + `uv.lock`), not an inheritance from the root workspace umbrella. Pinned by `tests/unit/test_ingestion_consumer.py` (41 tests; **77 passed** across the four-file fence — 41 + 10 + 22 + 4, the other three unmodified), no Redis/DB/network. **Adversarial review 2026-08-03 → APPROVE, no P0/P1;** the four P2s were repaired in-branch: a `asyncio.timeout(_DISPATCH_TIMEOUT_SECS=30.0)` around `emit_event` (one serial loop drains all three streams, so an unbounded await turned a per-event hang into a **bus-wide, silent** stall — strictly worse than the pre-cutover `BackgroundTasks` hang it replaces), a test pinning the lifespan start/stop wiring itself, one shared ordered timeline so criterion A can tell ack-after-dispatch from ack-before-dispatch (the line BO-20b edits), and `assert task.cancelled()` instead of the weaker `task.done()` — **the reviewer's last item was half a fix**: cancelling a task that has never been stepped makes asyncio raise `CancelledError` above the loop's `try`, so `task.cancelled()` passes against a swallowing loop too; the test now waits for the loop to reach its first read before stopping, and was verified red against a deliberately-swallowing `_consumer_loop`. ⚠️ **Ships OFF and is inert in every environment:** `INGESTION_CONSUMER` is unset everywhere, so the loop never starts and the receivers still emit inline. **OWNER-GATE:** flipping `INGESTION_CONSUMER=1` (registered in §6) — it is not just "start a loop": the same flag cuts the three provider receivers over to enqueue-only, so **Redis down = provider events dropped** rather than dispatched inline. That drop is now logged loudly (`.queue.dropped`, warning) instead of being silent, and must not be "fixed" by re-emitting inline. **Interim semantics, deliberate:** BO-20a acks after dispatch regardless of outcome — honest `XACK` + retry + DLQ is **BO-20b**, now split in two. **BO-20b slice 1 BUILT 2026-08-03:** `event_hooks.emit_event` gained a **keyword-only** `raise_on_error: bool = False` — the strict mode the consumer needs to observe a failure at all, since `emit_event` swallowed every sink exception by design and BO-20b's retry logic is dead code without it. Default unchanged (swallow, log `event_hooks.sink_failed`, run the next sink — a webhook must never 5xx); `raise_on_error=True` propagates the **first** sink exception and skips the remaining sinks. Keyword-only so the three receivers' three-positional-arg `add_task(emit_event, source, event_type, payload)` can never reach it, and the default is pinned as the literal `False` via `inspect.signature` so a later PR cannot flip provider-facing behaviour silently. `consumer.py` is **untouched** — it still acks regardless of outcome. Three new tests (`tests/unit/test_ingestion_consumer.py` §J), four-file fence **80 passed** (44 + 10 + 22 + 4, the last three unmodified); both mutants (drop the `raise`, flip the default) verified red. **BO-20b slice 2 is open, and its SCOPE GREW on 2026-08-03** (adversarial review, repair round 1): slice 1 is *necessary but not sufficient*. `main.py:1074` registers exactly **one** sink, `workflows.triggers.dispatch_event`, and its whole body sits inside a `try/except Exception` that logs `workflows.event_dispatch_failed` and returns `[]` (`triggers.py:45-46`, `:90-104`) — so `raise_on_error=True` is a **no-op on the real registry**: slice 2 would have called it, `dispatch_event` would have swallowed, `emit_event` would have returned normally, the loop would have `XACK`ed, and the event would be **gone** with no retry, no PEL entry and no DLQ row — with every test green, because the suite registers a *raising fake* sink, a shape production does not have. Slice 2 therefore also owns a keyword-only strict path in `dispatch_event` (`triggers.py` joins its Files list; `tests/unit/test_workflows_slice2.py` joins its regression fence, 80 → 90 passed), with the failure boundary prescribed in §BO-20b: **propagate** the `_get_db`/trigger-query failure and `RunRejected` (raised at `service.py:193-196` *before* the run row and the task, so nothing ran), **never** the per-run execution failures (fire-and-forget via `create_task` at `service.py:226` — re-delivering would start a *second* run of the same workflow on the same payload), and raise **after** the row loop so a partial dispatch is not made worse. §BO-20's non-goal "Not a change to `dispatch_event`" is **struck and qualified** accordingly — that is a third `DECISION (agent-proposed, owner may overrule)` on this row; the rejected alternative was to leave `dispatch_event` untouched and accept that the consumer cannot distinguish "dispatched" from "swallowed", i.e. BO-20b cannot deliver its guarantee. Slice 2 also carries two `DECISION (agent-proposed, owner may overrule)` entries recorded in §BO-20b, because the ticket as written was *satisfiable while doing nothing*: (i) **retry is PEL-and-reclaim, not an in-loop `asyncio.sleep`** — the prescribed `_backoff` schedule (1,2,4,8,16 s) was dominated by the same section's `_RECLAIM_MIN_IDLE_MS = 60_000`, so the two constants could not both be true; `_backoff` is **struck** (it was also unpinned at its *call site*, so it could be defined, satisfy all four asserted properties, never be called, and close green), a `_RECLAIM_EVERY_SECS = 30.0` periodic cadence is prescribed with a done-when that the periodic pass **exists**, and the attempt counter is `XPENDING`'s `times_delivered` (an in-process dict resets on restart ⇒ a poison entry never reaches the DLQ). The rejected in-loop model would have blocked **all three streams for ~165 contiguous seconds** per poison entry, reintroducing exactly what BO-20a added `_DISPATCH_TIMEOUT_SECS` to prevent; the accepted cost of the chosen model is retry latency quantised to the reclaim cadence (~5 min to succeed on the 5th attempt, ~6 min to DLQ). (ii) **a dispatch `TimeoutError` is a FAILED dispatch** (retry, then DLQ) — acking it is a silent drop, which is the thing this ticket abolishes; consequence: BO-20a's `test_a_hung_sink_times_out_and_the_bus_keeps_draining` must be **rewritten** by slice 2 (its ack assertion inverts; its bus-keeps-draining half is preserved). Also recorded: the DLQ write must **not** call the sync `queue.enqueue_dlq` from the async loop (fresh sync client per call at `queue.py:49`, blocks the loop, invisible to the `consumer._get_client` fake), and `XAUTOCLAIM`'s **third** reply element — ids whose stream entry `_MAXLEN` trimmed away — must be unpacked and logged, because on redis-py 7.1.1 the common two-element unpack raises `ValueError` and wedges the whole **drain loop** every cycle — the `try` at `consumer.py:294-298` spans `_ensure_groups` *and* `_drain_once`, so a failing top-of-iteration reclaim stops the bus draining entirely, at ~1 Hz, forever (the reclaim pass must be wrapped so its failure degrades to "no reclaim this cycle"). Also newly recorded in §BO-20b: `JUSTID` is **forbidden** (it suppresses the very delivery-counter increment the retry design rests on, and `redis-py` returns a bare id list that unpacks into three names *without raising*); the `XPENDING`-before-`XAUTOCLAIM` read order is pinned (the other order moves the observable DLQ threshold from 5 deliveries to 6 and no fake-backed test can tell); `times_delivered` counts **deliveries, not failures**, so a crash-loop burns retry budget on a healthy event (mitigated by recording it on the DLQ row); the reclaim's 60 s min-idle bound is **per entry, not per batch** and is safe today only because the loop is serial — a constraint now sits on **BO-20e** to bound per-entry idle before concurrency is enabled, or the same event runs twice; **per-stream ordering is given up** by PEL-and-reclaim and is now listed as an accepted cost (a stale `taskUpdated` can start a run after a fresher one); and the attempt counter survives a *gateway* restart but **not a Redis** one (`xgroup_create(id="$")` re-creates the group at the tail after a flush, and `infra/` sets no `appendonly`). ⚠️ **Two further "enqueued but never dispatched" states are now recorded in §BO-20a** beyond that accepted drop: the `XACK` is deliberately unguarded (a raising `xack` means Redis is gone and must reach the backoff, not hot-loop), and the loop only ever reads `">"`, so an ack failure or a SIGTERM **mid-batch** strands the rest of that `XREADGROUP` reply in the PEL under the old pid's consumer name. Only BO-20b's reclaim pass recovers them, and only until `queue._MAXLEN` trims — so **BO-20b's done-when now requires the reclaim pass to run at startup**, not only on the periodic cadence, and carries an explicit open sub-question about the min-idle bound at startup. **BO-20f (Gmail + Zoho receivers reach ClickUp enqueue+emit parity) shipped 2026-08-02** and is what multi-channel event triggers actually needed; it is still **inert in prod** — `zoho_webhook_secret` and `gmail_pubsub_token` default to `""`, both receivers fail closed, and **OWNER-GATE (an agent can do neither):** provision `ZOHO_WEBHOOK_SECRET` + `GMAIL_PUBSUB_TOKEN` on the VPS (`.env.example` is itself OWNER-GATE under WS-2 — the plan-guard hook blocks agent writes to it) **and** point the provider subscription/webhook at `/webhooks/{zoho,gmail}`. The fail-closed posture is correct and must not be changed. ⚠️ **Not a greenfield build:** webhook→run was ALREADY wired — ClickUp → `ingestion/event_hooks.emit_event` → `workflows/triggers.dispatch_event` → `start_run` since commit `e20ea830`, and `/agent/webhook/{source}` (`routes/agent.py:3476-3478`) is a second live path that calls `dispatch_event` **directly** and is **untouched by the cutover** — so §BO-20 Q1's old "the consumer becomes the single dispatch path" was loose and is corrected there to "the only caller of `emit_event`". **Remaining: BO-20b slice 2 → c → (d, e)** — retry via PEL reclaim + honest `XACK` + DLQ hand-off, a drainable/visible DLQ, per-source rate limiting, bounded concurrency; all ✅ AGENT-SAFE, each waiting only on its predecessor. **WS-11 Slice 4 still waits**: `workflows_app.md:217` defines it as "(post-BO-20/BO-7): durable queued runs; …", and durable means a–e — without BO-20b a failed dispatch is acked and lost. BO-9 resolved as **not blocking** (the consumer owns its own long-lived async client; the producer's per-call sync `queue._client` stays BO-9's, untouched here). | -| WS-5 | **CI gates real** (BO-17/BO-18) | checklist §F | 🟡 Docs | Un-gate evals, blocking gitleaks, coverage floor. ~~AGENT-SAFE~~ → **mixed: the highest-value item is a GitHub *settings* change an agent cannot make.** **Audited 2026-08-01 → NO-GO**: §F has zero testable "done when" ("per the existing plan", "a few green PRs", "for foundation packages"), its ratchet-plan anchor points at a path that moved to `specs/archive/` (3 stale citations live *in the workflow files*), and BO-17 reads ☐ while half of it shipped (blocking ruff-correctness + xenon, a frontend tsc/vitest job, gitleaks, per-PR health). **THE MISSING ITEM — why the 2026-08-01 F821 escape happened, in no doc today:** (1) `main` has **no branch protection** (`gh api …/branches/main/protection` → 404) — every "blocking" gate in these YAMLs is decorative; (2) commits pushed straight to main get **zero check-runs** (`15c8933f` had none); (3) `deploy.yml:56-58` lints with the *non-blocking full* `ruff check .`, **not** the `--select F821,…` correctness gate, so deploy went green over a broken tree; (4) PR #318's `pr-check` **failed on that exact F821 and merged anyway**. **Slice when specced (BO-17a "main-guard"):** add a `correctness` job to `deploy.yml` on push-to-main running the `--select` gate, deliberately NOT in the deploy job's `needs:` — loud, not blocking. AGENT-SAFE. **OWNER-GATE:** enabling branch protection / required checks, wiring any gate into `needs:`, removing `skip_tests`; BO-18's purge+rotation is WS-2's, not this row's. Refuted two long-standing beliefs: pr-check **does** cover the frontend, and it **does** run on non-main branches. | -| WS-6 | **Observability wiring + attribution** (BO-5 + decision D1) | `observability_e2.md` **§7** | 🟡 partial | **Docs gate CLEARED** (PR #319 added the numbered §7 with nine lettered tickets WS-6a–i, per-item done-whens and gate labels). **Re-audited 2026-08-02 → GO-NARROWED to WS-6a+WS-6c only.** ✅ **BUILT 2026-08-02, pending review:** D1's attribution stamp exists as a substrate — `instance` joins `_RUN_CONTEXT_KEYS`/`bind_run_context`, resolved once in `run_agent_stream` via a **second additive bind** after `load_agent` (the early bind stays: it is what correlates a failure *during* load; moving it would trade 5 fields for 1), and `_emit_usage` carries the full (run, member, agent, instance) tuple with **zero call-site changes** — it arrives by inheritance via `activity._INHERIT`. Shared agents produce an **absent key, never `''`** (double-guarded + pinned). `refresh_run_presence()` patches `cc:activity:live:{run_id}` after the late bind, so `/observability/active` + `/roster` carry it; interim `by_instance` cost dimension added to the Redis rollup. **Nothing durable is written yet** — logs + Redis feed only. **🔴 WS-6b/6d/6e HELD, still NO-GO:** WS-6b's security amendment names *no workable mechanism* — `bind_run_context` has one call site (`executor.py`), contextvars do not cross the HTTP hop to `v1_compat`, and `agent_run` rows are written at the run *boundary* so a mid-run join finds nothing. **The only mechanism the code supports at request time is the presence key `cc:activity:live:{run_id}`**, which for the orchestrator path carries a server-established `user`; §7 must name it (or name another) before WS-6b dispatches. WS-6e has no token source (`build_run_trace_row` is pure over events+folded) so it sequences *after* WS-6b, not independently; WS-6d additionally waits on the retention/PII answer (Q3). **Two recorded asymmetries** — the `phase="start"` event predates the bind, and **a delegated sub-run inherits the caller's partition** while its blobs key to `''`, so WS-6d must not treat `instance` as a foreign key onto `agent_blob.instance`. **OWNER-GATE:** WS-6f/g/h/i (Langfuse keys, `--profile obs`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `LLM_USAGE_AUDIT`, the MAF telemetry kill switch) — all now listed in §6. | -| WS-7 | **Memory activation + search** (BO-21 → BO-22) | checklist §C + `llm_caching_memory.md` | 🔴 | **OWNER-GATE:** flipping `MEM0_ENABLED`/`GRAPHITI_ENABLED` in prod (cost + latent findings in `agent_platform_hardening` Part 5). `acb_search` (BO-22) after. | -| **WS-24** | **Colleague onboarding readiness** — the gate, the runbook, and the capability matrix *(minted 2026-08-04)* | `specs/colleague_onboarding.md` | 🔴 **NOT READY — but the shape changed on 2026-08-05: every AGENT-SAFE item is now BUILT, MERGED and DEPLOYED, and what remains is two owner actions on the identity boundary plus two on backups.** `main` @ `74082882` is live on the box: migration 143 applied (`access_request` exists), both services active, and the first real backup this deployment has ever taken landed at `/opt/acb/backups/2026-08-05T044202Z` (22 MB data dump) because #347's pre-migration gate fired. **N6a** (sign-in queue), **N7** (self-lockout guards on three doors + a Remove control), **N8** (hard delete) and the **OAuth connect-flow P0** all shipped. ⚠️ **Two findings measured against the running deployment, both OWNER-GATE, both in §6:** `GATEWAY_INTERNAL_TOKEN` is **byte-identical** to `LITELLM_MASTER_KEY` (same sha256), and gateway `:8080` + workbench `:3001` answer from the public internet, so Caddy's identity strip can be walked around. Until both are closed, every owner predicate in this plan is applied to an identity that can be forged. **The build rules an app must not deviate from now live in `specs/user_management_contract.md`** (§4 registry). Historical state below. ✅ G4 CLOSED 2026-08-04 — all FOUR tickets shipped:** N4 (`ws-24-n4-people-scoping`) the Tasks people directory is *directory open, HR fields restricted* with all four writes on `admin:members:manage`; **N1–N3** (`ws-24-n1n3-notes-scoping`) the Notes owner-scoping remainder. **G1/G2/G3 unchanged — inviting anybody is still unsafe.** **✅ N6a BUILT + REPAIRED 2026-08-04** (`ws-24-n6-signin-requests`, spec §6) — the sign-in queue: migration 143 `access_request`, `resolve_access(record_request=)` gated to the request path only, `GET/POST /admin/members/requests…`, a Requests tab, and `invited` rows now labelled "never signed in". ⚠️ **A same-day adversarial review found a P1 cross-gate escalation** — approve could reinstate an off-boarded member on the weaker `admin:members:invite`, because a decided `access_request` row outlives the decision and the `ON CONFLICT` guard matched `removed`; and the test that claimed to fence it was a Python mirror of the same SQL, so the exact mutation passed all 28 cases. Both fixed: a decided request cannot be re-decided, provisioning never activates a row that is not `invited`, and the fence is now a structural assertion against the statement string. ⚠️ **A SECOND pass then found the half that fix left open, and it was the more damaging one:** the provisioning guard declines *silently*, so approve still returned **200**, still marked the request `approved`, and still re-granted `['member']` to the off-boarded member — which removed the still-locked-out person from a tab that renders only `pending`, permanently, since the resolver's upsert never rewrites `status`. **That is the 53-knock incident recreated by its own fix.** Closed by `APPROVE_MATRIX` (spec §6 *Repair round 2*), read before anything is written: absent/`invited` → provision; `active` → leave their roles alone and say so; `suspended`/`removed` → **409 and the request stays `pending`**. The invariant now stated and fenced: **approve never rewrites the roles of a member who already exists in a state other than `invited`** — the same defect demoted a live `admin` to `member` on `admin:members:invite`. `_DECIDE_SQL` also binds the read's status filter into the write, so a lost race discards its own provisioning. ⚠️ **A THIRD pass found the same shape once more — a race this time, not a sequence — and with it the reason it kept recurring.** `APPROVE_MATRIX` is read *before* the write, which closes the sequential holes but not the concurrent one: `_PROVISION_MEMBER_SQL`'s `CASE` arms are re-evaluated by Postgres against the latest **committed** row, so a second admin off-boarding the same person between approve's `find_member` and its upsert lands every arm on `ELSE app_user.status` — the provisioning declines silently and approve stamps `approved` over it, losing the still-locked-out person from the queue permanently. **The structural cause, now stated in spec §6 so it is not rediscovered a fourth time: approve verified by *prediction* — it read the row, decided what would happen, and never read back what did.** Fixed by requiring the member to be `active` before the decision is stamped; nothing is committed until then, so a refusal abandons the provisioning with its transaction. Also fenced the matrix's fail-closed default, which nothing pinned. ⚠️ **The first version of that fence was itself too weak** — asserting only `409` passed while the matrix was wide open, because the new read-back check raised its own 409; the discriminator is that a matrix refusal grants no role. **That is the third test in this ticket to assert less than its docstring claimed.** 52 tests, eight mutants measured red and reverted sha256-identical. N6a is **not a gate** and does not move this row's colour; **merging it IS an owner gate** (§6 of this plan — `deploy.yml:202-203` replays migrations, so the merge arms an auth-behaviour deploy). N6b needs no code; one owner question (auto-promote on first sign-in?) is recorded in spec §6. **✅ N7 BUILT 2026-08-04** (`ws-24-n7-self-removal-guard`, spec §2 Step 5) — **off-boarding yourself.** `DELETE /admin/members/{email}` refused the caller; `PATCH /admin/members/{email} {"status": "suspended"}` reaches the identical `is_active=False` and had **no self-check at all** — it refused only because `assert_owner_survives` happens to fire in a one-owner org, so **adding the second owner §2 Step 2 exists to create opened it**, and `admin:members:manage` is the floor for undoing it. The Members page rendered the button, because it never learned who the viewer was. One shared guard now (`_common.assert_not_self_lockout`) called by **both** doors; the rule is **"any status that is not `active`"** rather than a list, so `invited` — equally a lockout, since `is_active` is `status == "active"` exactly — is covered by construction; comparison case-insensitive and empty-safe. The roster reads `access.email` and renders **This is you** where Suspend/Remove were, and the shipped-but-uncalled `DELETE` finally has a UI behind a confirmation that names the person. ⚠️ **Both guards answer 409**, so every refusal test discriminates on the detail text *and* on what was written; the dw4 pair seeds **two** owners so only the self-guard can be answering. 22 new cases + 8 vitest; six mutants measured red and reverted (PATCH guard deleted → 8 red incl. dw4; DELETE guard deleted → 2; `.lower()` dropped → the 4 casing cases; rule narrowed to an enumeration → the `invited` fence; browser guard ignoring self → vitest; Suspend rendered unconditionally → the page-wiring case). Test fake extracted to `tests/unit/_admin_fakes.py` and shared with `test_signin_requests.py` (52 passed, unchanged) rather than copied. **No migration and no new slug, so unlike N6a merging it is not an auth-behaviour deploy gate**; it is not a §1.1 gate item and does not move this row's colour. **✅ N8 BUILT + REPAIRED 2026-08-05** (`ws-24-n8-purge-member`, spec §2 Step 5) — **deleting a member permanently.** Remove was the only off-boarding and it is soft by design (status → `removed`, grants dropped, `app_user` kept because ~every user-scoped table keys people by address); that stays. `DELETE /admin/members/{email}/purge` is a **second, harder action beside it, never a `?hard=` flag** — a flag would put the irreversible path one typo from the reversible one. Decision: **purge the person, keep their work.** The identity, every grant, every credential, their private sessions and their `access_request` row go; what they authored and **the audit trail** stay, and nothing is anonymised (the address is the join key across ~50 tables, so scrubbing `owner_email` orphans their apps rather than hiding them). Fourth door on the one shared `assert_not_self_lockout`, plus `assert_owner_survives`; one transaction, audited before the commit, a count per table in the response. ⚠️ **Verification returned FAIL and the headline defect was a count that lied in the reassuring direction.** `task_accounts` cascades the SYNCED half of `gtd_items`, and the KEEP clause counted those rows anyway — 847 synced tasks came back as `kept: {"tasks": 847}` with all 847 destroyed; `gtd_projects` (same cascade) was on neither list. **The response did not miss a destruction, it reported it as a survival.** Fixed by splitting both tables on `account_id` the way `chat_session` is split on `visibility`. **Why nothing caught it is the durable lesson: every structural assertion compared a row-spec to itself, and the test fake models no foreign keys — so no cross-table claim was checked by anything.** `tests/unit/_schema_cascade.py` now derives the FK cascade graph from the numbered migrations and three fences use it (no KEEP clause inside the delete side's blast radius unless it is the exact complement of a DELETE clause; every cascade child with its own person column must be reported; the hand-maintained cascade map is compared to the schema). Two more gates were unfenced and are now pinned: **deleting the route's `require_permission` left 162 tests green** (the fallback floor is `admin:members:read`, which `manager` holds — hard-delete for every manager), and **`const confirmed = true;` in the confirmation left 32 pytest + 173 vitest green** (done-when 6 was tested by grepping for copy; the rule now lives in `confirmPurge.ts`). The cascade map was also understated in the dangerous direction — 15 of 20 email tables, `wa_media` one hop too high — now derived and pinned. Recorded not fixed: `acb_audit/log.py:49` swallows every exception, so "the audit entry survives a rollback" is true but "a completed purge always leaves an audit row" is not. 39 + 28 + 152 pytest, 178 vitest; nine mutants measured red and reverted. **No migration and no new slug**, so like N7 and unlike N6a, merging it is not an auth-behaviour deploy gate; it is not a §1.1 gate item and does not move this row's colour. | **Read this row before inviting anybody, and before assuming any other row's access work is safe to demonstrate with a second person.** Exactly one member is signed in (`vjvarada@fracktal.in`, §4). The question "is it safe to invite colleagues" had been re-derived in conversation repeatedly and recorded nowhere; the spec is the durable answer and `scripts/onboarding_preflight.py` is its executable half (**agent-safe to write, NOT to run against prod — `--mode local` is an agent's only mode**; it refuses the box-only checks rather than guessing, because `resolve_access` degrades to `is_active=False` on an unreachable DB too, so a local PASS on default-deny would be vacuous). **The blockers, each with a done-when in §1.1 — G4 is the one that closed: G1** the Caddy strip — `deploy/hostinger/caddy/Caddyfile:13-18` has **no** `header_up -X-User-Email` / `-X-User-Role`, and `acb_auth/deps.py:27-35` says in its own docstring that the reverse proxy IS the boundary, because nothing in that module can tell a forwarded identity header from a forged one. 🔴 OWNER-GATE to install (writing the repo file is agent-safe). **G2** `GATEWAY_INTERNAL_TOKEN` unprovisioned ⇒ service identity falls back to `LITELLM_MASTER_KEY` (`deps.py:108-117`), the key every agent's BYOK client holds; `GATEWAY_REFUSE_LLM_KEY_IDENTITY` (PR #346) makes that refusable and **ships OFF**, and is inert once the token is set. 🔴 OWNER-GATE (a credential, in two places — the Next BFF mirrors the same fallback at `lib/gateway.ts:58-61`, so flipping the flag with the token unset 401s every signed-in member). ⚠️ **G2 has a LOCKOUT mode, repaired in the preflight 2026-08-04.** Setting the token in `/opt/acb/app/.env` only — which is what "restart the gateway and the workbench" invites — leaves the BFF sending `sk-local-dev-change-me`, so every proxied browser call carries a bad Bearer with a real `X-User-Email` while an internal token *is* configured, and `deps.py:356-361` returns **NO_ACCESS for every signed-in member**. Check 1 read only `.env` and would have certified that state green; it now reads `workbench/control_plane/.env.local` too and FAILs naming the lockout when the two disagree. Do it by **redeploying** — `.github/workflows/deploy.yml:166-187` reconciles `.env.local` from `.env` in place on every deploy, so the only dangerous window is "provisioned by hand without a redeploy", which is exactly what a hand-run owner gate looks like. **G3** a restore path — **BO-23 is unbuilt**: there is no data-inclusive dump, no `pg_restore` inverse, no restore runbook and no pre-migration hook; `scripts/dump_schema.sh` is `--schema-only` (structure, zero rows). `scripts/backup_db.sh` and `restore_db.sh` are proposed on the **independent** PR #347 (`ws-0-bo23-backup-restore`) and are **not on this branch**. 🟢 agent-safe to write, 🔴 owner-gate to run or schedule. ⚠️ **Repaired 2026-08-04:** the preflight's check 4 used to assert an `acb-backup.timer` unit and a `MANIFEST.txt` that **BO-23's own done-when never specifies**, while testing no dump format, size or restore script — so a schema-only dump printed "Backups run, land, and are recent" over zero rows, and G3 could not have gone green even after BO-23 shipped exactly what it promised. It is now measured against `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-23 done-when 1-4 verbatim, plus a size floor on the newest dump; the timer is probed as a note, never asserted by name. **G4** the four owner-scoping holes (below) — **all four closed 2026-08-04, so this gate IS green. WS-24 is not**: G4 closes the holes that survive a *correct* identity, and G1/G2 are about the identity itself — an owner predicate applied to a forged `X-User-Email` is not a control.** **PR #348 IS in this branch's ancestry** — `permissions.py:95-100` carries the six `center.*` slugs, so the preflight's Centers check passes here. **✅ G4's N4 CLOSED 2026-08-04** (`ws-24-n4-people-scoping`, spec §4 N4's `owner-answered` DECISION block): **directory open, HR fields restricted.** `GET /tasks/people` still serves the org chart to any `feature:tasks` holder, but `skills`, `skills_source`, `resume_summary`, `years_experience` and capacity/current-load/available are projected to null/empty for a caller without `admin:members:read` (`routes/tasks/people.py` — `HR_FIELDS`, `_row_to_person(row, *, include_hr)` with **no default**, so a future route cannot inherit the permissive answer), and `?q=` drops its `unnest(skills)` clause for that caller so the search box cannot become an oracle for the field the strip exists to hide. All **four** write routes — `POST /people`, `PATCH /people/{person_id}`, `POST /people/{person_id}/resume`, and `capability.py`'s `POST /people/embed` — carry `require_people_write()` = `admin:members:manage` as a route dependency (`routes/tasks/core.py`). **No new permission slug** was minted, deliberately: a new slug is nobody's grant until an admin creates it, which would switch HR features off for the owner too; both permissions are existing `CAPABILITIES` entries and the owner's `*` matches both. Consequence recorded, not a defect: a `manager` (holds `admin:members:read`, not `:manage`) sees the HR half and cannot write it — consistent with the matrix. `fetch_people_for_clarify` is **unchanged** and still returns full rows: the projection is at the serialization layer, never in the SQL, so in-process agent delegation (`ai.py`, `capture_email.py`, `planning.py`) is untouched. `tests/unit/test_tasks_people_scoping.py`, 35 cases, three mutants verified red first. **✅ G4's N1–N3 CLOSED 2026-08-04** (`ws-24-n1n3-notes-scoping`, cut from `891903de`), all three reachable until then with the default `member` role because it holds `feature:notes` (`130:235`). **N1** — fifteen of the sixteen routes in the six named files (`recordings.py` upload/start/chunk/complete/audio, `qa.py`, `share.py`, `copilot.py` ×2, `live.py`'s `/stt/live-token`, `actions.py` ×3) now load through `core.load_owned_meeting` or bind `core.OWNED_MEETING_PREDICATE` and answer **404, never 403**. `_recording_path` — the loader `/chunk` and `/complete` share — carries the join, so neither can acquire the hole separately and the per-chunk path pays no extra round trip; `qa` loads the meeting **before** the transcript so the 409 "no transcript yet" stops being an oracle; the copilot **stream** checks before the `StreamingResponse` starts, because a 404 raised inside a started stream is a broken connection, not a refusal; `share.py` was read first and has no sharing mechanism to preserve (no grant, no token, no redemption — the send is a separate `/email/send` under the caller's own account), so the whole route is a read. **`live.py:256` stays machine-authed by recorded decision** — the caller is the bot worker with `MEETING_BOT_TOKEN` and no member identity, so an owner predicate has no owner, and both ways to invent one turn the bot token into a way to *assert* an identity; it discloses one boolean plus a settings-derived sentence, and the same answer for an id that does not exist. **N2** — `actions._load_action` joins `meeting` and binds the predicate, so both single-item routes inherit it; the test pins **both** harms separately (no `INSERT INTO gtd_items`, no `UPDATE action_item`, and the colleague's description never reaches a bound parameter), because a 404 alone would not have proved the exfiltration half. `approve-all` was *aligned* rather than left alone: already safe at the `_dispatch` seam, it answered **200 with an empty list** — "your meeting, nothing qualified" where the truth was "not your meeting" — and read the colleague's draft rows to get there. **N3** — the attach branch binds the predicate **into the `UPDATE`** (`UPDATE meeting AS m … WHERE m.id = … AND (lower(m.owner_email)=lower(:owner) OR m.owner_email IS NULL) RETURNING m.id`) rather than loading first: a load-then-write leaves a window, and this statement *is* the mutation. The acting principal is the **caller**, necessarily — it is the only identity the request carries, and checking the row against its own `owner_email` would compare the meeting to itself and pass every time; the asymmetry is preserved, not collapsed, and a test pins that the ingest side still reads `meeting_bot.requested_by`. Evidence: `tests/unit/test_notes_owner_scoping.py` 21 → **57 passed**, every non-owner case verified **red** against pre-fix behaviour *with the parameter renames already applied* (so each red is the security claim, not a `TypeError`), plus four mutants — drop the audio guard, drop the action-item predicate, drop the `bot_join` predicate, and compare against the wrong identity — each red on exactly its own cases with the tree byte-identical after revert. Notes suite **280 passed**, `test_org_access_enforcement.py` **31 passed**. ⚠️ **TWO findings recorded, neither fixed here.** (a) **N1's table was not exhaustive** — `routes/notes` has 24 modules and **nine** still carry zero owner predicates after this change (`summaries.py`'s `GET`/`PUT /meetings/{id}/note` + `GET .../actions`, `copilot_context.py`, `copilot_agenda.py`, `meeting_bot.py`'s four `/bot/*` routes, `live_transcript.py` incl. `POST /meetings/{id}/say` — which makes the notetaker *speak into somebody else's call* — `live_session.py`, `speaker_id.py`, `agenda_progress.py`, `events.py`). Minted as spec §4 **N5**, deliberately **outside G4**: G4's done-when is "each of §4's four tickets meets its own done-when" and all four do, and re-scoping an owner-facing gate is the owner's call. **Owner decision needed:** does N5 block colleague #1? (b) `/notes/meetings/{meeting_id}/live/wanted` is in **neither** `main.PUBLIC_ROUTES` **nor** `core.router`'s `exempt` list while both its siblings are in both — so `require_authenticated` and then the feature gate 401 the worker before `_check_bot_auth` runs, and the poll that decides whether to keep paying for streaming ASR is dead. Not fixed here because the fix *opens* a route, the opposite of this change's direction. `test_org_access_enforcement.py`'s own `GATED_ROUTERS` lists the path, which is how the drift stayed invisible — that registry is the test's opinion, not the router's. **Two findings that correct the received account of the roles, both in spec §3.0 — anything quoting `130` alone is wrong:** (a) role grants come from **two** migrations — `131_integration_memory_permissions.sql` additionally gives `member` `integrations:use:*` **and `memory:read_org`** (`131:70-78`), gives `manager`/`admin` `memory:write_org` too, and gives `guest` **nothing** (`131:80`); (b) **`data:org:read` grants nothing — it has zero consumers.** It is declared (`permissions.py:132`), granted to admin/manager/agent_service (`130:205, 221`) and listed in the legacy fallback (`access.py:148`), and **no route, query or predicate in the tree ever checks it**. So "manager has org-wide visibility" is a name, not a mechanism; what actually widens a manager is `admin:members:read` (the floor for the **whole** `/admin` package, `admin/_common.py:77-91`, and `is_admin: true` at `me.py:96`), plus `feature:approvals`/`observability`/`whatsapp` and `memory:write_org`. That is **D14**. **Three more measured cells worth carrying up here** (full matrix in spec §3): `feature:memory`, `feature:artifacts` and `feature:observability` are enforced **nowhere server-side** (`memory.py:45-48` gates on the internal Bearer then per-scope; `workspace.py:53` and `observability.py:46-51` gate on nothing beyond authentication) — they hide a nav pane and the per-object rule is the boundary, exactly as `lib/access.ts:126-129` says; **artifacts are shared for most agents**, because 4 of the 6 first-party `config.json`s declare `instancing: "shared"` ⇒ `instance_key()` = `''` ⇒ one workspace for everybody (`workspace.py:230-260` → `manifest.py:235-246`); and a **member can read/write every agent's memory compartment**, since `_authorize_agent` (`memory.py:103-109`) gates on `can_run_agent` and member holds `agents:run:*`. **Granting `feature:workflows` is a labelled consequence, not a defect** (spec §3.4): org-wide read is a recorded v1 decision (`crud.py:1-5`), the detail response returns `hook_token` (`crud.py:230`), and the hook route is unauthenticated by design (`core.py:29`, `hooks.py:3` — "the token IS the credential"), so the grant hands over a permanent copyable trigger for **every** workflow that survives off-boarding, and there is no rotate endpoint. **Not in this row:** building spec §4's new **N5** (the nine further `routes/notes` modules) until the owner says whether it blocks colleague #1, per-Center *data* scoping (WS-14/WS-15 — `140_center_features.sql:9-12` is explicit that Center features gate navigation and the landing pages, not data), and shared mailboxes (ownerless, §4). | - -| **WS-25** | **Deploy delivery path** — getting merged code onto the box *(minted 2026-08-05)* | `specs/deploy_delivery_path.md` | 🔴 **BROKEN — but no production impact to date** | **`main` is `d7d5c79b`; the box is `74082882` (#347).** ⚠️ **This row first claimed five PRs were stranded "including #355, the OAuth authorize fix" — CORRECTED the same day: #354, #355 and #356 are all LIVE.** The deploy does `git reset --hard origin/main`, so #347's successful 04:40 run carried everything merged before it, and those three merged by 01:18. **The error was reading box HEAD as if delivery were PR-by-PR — it is not: one successful deploy lands every commit merged up to that instant, so "the box is on PR n" says nothing about PR n+1, only about when the last success ran.** Verified per PR with `git log --grep` and by the BFF OAuth route on disk dated 04:42. **Actually stranded: #357 + #358 — eight files, all documentation plus `scripts/backup_db.sh`; zero executable app code, zero migrations, and nothing reads them at runtime**, so today's remediation is a `git fetch && git reset --hard origin/main` with **no deploy and no restart**, not the §6 stopgap. **The cost of this defect is therefore entirely forward-looking: the next app change to merge will not ship, and nothing will say so.** **Measured 2026-08-05:** deploy runs since 2026-08-04 alternate ~4-minute successes with **~54-minute failures** (the retry ladder running to exhaustion) — `ssh: connect to host ***: Connection timed out`, and `workbench=000000`, curl's no-response code, so the runner's **HTTPS** probe got nothing either. **The box was healthy the whole time:** across the 55-minute window 06:28–07:23 UTC `journalctl -u ssh` logged **four** lines — one operator key login and two immediately-closed scans — at load average 0.16, uptime 7 days, no reboot, while answering the operator's machine in 240 ms. No fail2ban (not installed), no iptables rules beyond UFW's own chains. **GitHub's packets do not arrive; the drop is upstream of the VPS and affects every port.** The asymmetry is the whole design input: the box reaches GitHub **outbound** fine (`git ls-remote` instant, `api.github.com` 200 in 29 ms). ⚠️ **`deploy.yml:546-559`'s existing retry logic cannot save this — it models the wrong failure.** It assumes the deploy *ran* and only the SSH teardown flaked, so it ignores the SSH exit code and verifies by health probe; sound for a teardown blip, useless when the session never establishes, and it converts a 4-minute no-op into a 54-minute one. ⚠️ **The structural obstacle, and why the obvious fix is a trap: `DEPLOY_SCRIPT` is a 435-line shell script defined as a workflow `env:` value (`deploy.yml:107-544`) and piped over SSH with `bash -s` — the box never holds a copy.** So a pull-based scheme must either duplicate 435 lines on the box, producing two deploy paths that silently drift (worse than the outage), or the script must first be extracted to a versioned file (**D1**) — which pays for itself anyway, since a script embedded in YAML cannot be shellchecked, hand-run during an incident, or diffed. **Second-order trap recorded in the spec §3:** the script's first act is `git fetch && git reset --hard origin/main`, so a box running it *from the checkout* has the file rewritten while bash is still reading it by byte offset; extraction must be two-stage — a small stable bootstrap that fetches, then `exec`s the fresh script. **Options in spec §4, recommendation A:** (A) a pull timer polling `git ls-remote`, depending only on the outbound path that is proven working, no daemon executing remote-authored jobs on the production host; (B) a self-hosted GitHub runner — far less bespoke code and keeps the Actions audit trail, but puts a job executor holding repo credentials on the prod box, acceptable only while the repo stays private and no forked PR can target it, a property that must then be *maintained*; (C) a Hostinger ticket, worth filing in parallel, worth waiting on for nothing. Under **both** A and B the health check can no longer prove external reachability — unavoidable today, since GitHub cannot reach the box to check. **D3 (failure is visible) is not optional:** this ran two days because the only signal was a red tick on a page nobody watches while the app stayed up and looked fine. **All four acceptance items are OWNER-GATE** (they change the deploy path and apply migrations forward-only). **§6 stopgap: the operator's own machine reaches the box, so the existing deploy can be driven by hand** — and the preconditions are already true as of 2026-08-05 09:29 (a verified restorable backup, `live=228 restored=228`, plus the nightly timer installed and enabled), which makes this the safest moment this deployment has had for it. **Blocks:** §6's `GATEWAY_INTERNAL_TOKEN` rotation, whose prescribed method *is* a redeploy — rotating before delivery works writes the new value into `.env` with no reconcile of `.env.local`, the exact lockout that item warns about; and `colleague_onboarding.md` §2's final step. | +| WS-1 | **Action Broker truth + completion** (BO-1) | 🟢 | `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-1 · board record 2026-08-09 | Broker loop LIVE and writing; handlers register at SIX sites; `crm.zoho_*` handlers live and the Zoho sync loop is **running** (§6 WS-26 (a)). Open, each AGENT-SAFE, one PR: **BO-1a** two unrouted ClickUp writers (approved delete/archive → `failed` rows) · **BO-1b** pending-marker ignored by `items._push_pending_item` (green "synced", empty `provider_task_id`) · **BO-1c** the email-verb decision, then handlers. 🔴 `ACTION_BROKER_ENFORCE` flip only after 1a+1b (§6). (2026-08-07) | +| WS-2 | **Secrets** (BO-8: rotate Zoho token, purge history, fail-closed) | 🔴 | checklist §BO-8 + `FOUNDATION_CONTINUATION.md` | OWNER-GATE end-to-end (force-push history purge, credential rotation). Standing P0 since 2026-07-11. WS-26e / WS-27g cutovers execute the Zoho / ClickUp revoke halves (§6 WS-26 (c), WS-27 (c)). | +| WS-3 | **Isolation ladder** (BO-7 · HH-6 · T0–T2) | 🟢 a+b · ⏸ T2 | `permissions_sandbox_b6.md` §P5 · board record 2026-08-09 | P5-a (credential scoping), P5-b.1 (ceilings), WS-3a (record+refuse), WS-3b (rootfs+network) shipped. **T2/P5-c re-framed by D16 (2026-08-08):** parked as a **precondition of the §5.1 pooled cutover** (customer 8–12) — no longer "until a second org appears"; acceptance stays unwritten until the owner un-parks (§6 first blockquote). P5-b.3 scoped gateway key: unbuilt *and undesigned*. MT-0b's `organization.first_party` (migration 157, scratch-applied) retires this row's old "no `first_party` field exists anywhere" note. 🔴 flips: `AGENT_PERMISSION_MODE`, `ISOLATION_TIER_ENFORCE` (§6). (2026-08-03 · re-framed 2026-08-09) | +| WS-4 | **Event-bus consumer + durable queue** (BO-20) | 🟢 a+f+b1 | checklist §BO-20 — **file at the REPO ROOT** · board record 2026-08-09 | §BO-20.0 answered: **Option A, in-process** (owner 2026-08-02). Built: BO-20a consumer (reviewed, four P2s repaired) · BO-20b slice 1 · BO-20f receiver parity (inert). Next, AGENT-SAFE in strict order: **BO-20b slice 2** (strict `dispatch_event` path + PEL/XAUTOCLAIM reclaim — the record pins eight traps; read it first) → BO-20c → (BO-20d, BO-20e). 🔴 `INGESTION_CONSUMER` flip (§6) + provisioning `ZOHO_WEBHOOK_SECRET`/`GMAIL_PUBSUB_TOKEN` on the box — ⚠️ D15 coda: those become **per-org** secrets at MT-1a+; one box-wide value cannot serve N tenants. (2026-08-03) | +| WS-5 | **CI gates real** (BO-17/BO-18) | 🟡 Docs | checklist §F · board record 2026-08-09 | Audited 2026-08-01 → NO-GO (§F has zero testable done-whens). ~~"main has no branch protection"~~ **struck 2026-08-09** — protection was ENABLED 2026-08-03 (exceptions row 1); the row had never been swept. Deploy still lints with non-blocking `ruff check .`. Ready slice: **BO-17a main-guard** (`correctness` on push-to-main, deliberately NOT in `needs:`) — AGENT-SAFE. 🔴 GitHub *settings* changes (required checks, `needs:` wiring, `skip_tests` removal). BO-18 → WS-2. (2026-08-01 · corrected 2026-08-09) | +| WS-6 | **Observability wiring + attribution** (BO-5 + D1) | 🟡 partial | `observability_e2.md` §7 · board record 2026-08-09 | WS-6a + WS-6c BUILT 2026-08-02, pending review — attribution reaches **logs + Redis only, nothing durable**. WS-6b/6d/6e **HELD NO-GO**: no mechanism carries run identity across the HTTP hop to `/v1` (contextvars don't cross it; `agent_run` rows are written at run boundary); do not dispatch until §7 names one. 🔴 WS-6f–i activation flips (§6). (2026-08-02) | +| WS-7 | **Memory activation + search** (BO-21 → BO-22) | 🔴 | checklist §C + `llm_caching_memory.md` | 🔴 OWNER-GATE `MEM0_ENABLED` / `GRAPHITI_ENABLED` prod flips (§6; cost + latent findings, `agent_platform_hardening` Part 5). `acb_search` (BO-22) after. ⚠️ WS-29 coda: Mem0 tenant binding is decided — **D17, conninfo option** — and the flip should land only with MT-1c's binding in place. | +| WS-24 | **Colleague onboarding readiness** *(minted 2026-08-04)* | 🔴 2 gates + 1 decision | `specs/colleague_onboarding.md` · board record 2026-08-09 | Every AGENT-SAFE item BUILT + MERGED + DEPLOYED (N1–N8; G4 closed 2026-08-04). ~~G3 backups~~ **closed**: BO-23 timer verified scheduled 2026-08-07, restore rehearsed 2026-08-05. Remaining: **G1** Caddy identity-header strip (§6 WS-24 (a)) · **G2** `GATEWAY_INTERNAL_TOKEN` split from `LITELLM_MASTER_KEY` (§6 WS-24 (b); rotation is a redeploy and delivery works again — see WS-25) · **N5** owner decision: do the nine unscoped `routes/notes` modules block colleague #1? ~~ports-open claim~~ closed 2026-08-05 (§6 identity item 2). ⚠️ D14 coda: `data:org:read` now has a consumer path (WS-27d) — re-verify the capability matrix before member #2. (2026-08-05 · corrected 2026-08-09) | +| WS-25 | **Deploy delivery path** *(minted 2026-08-05)* | 🟡 recovered — cause unverified | `specs/deploy_delivery_path.md` · board record 2026-08-09 | ~~🔴 BROKEN~~ **re-measured 2026-08-09**: deploys landing since 2026-08-06 (migs 144/145 applied on prod); six green runs on **2026-08-07 UTC** alone, the last = #400's log-verified deploy `31217978773` (2026-08-08 IST — `crm_app.md`'s dating; `c1eba71f` fixed the apply script git-resetting itself mid-read — the "six deploys reported success while shipping nothing" hole). Tip run (`b09093a8`, docs-only) failed **health-verify** ×3 rounds 21:21→22:16 UTC 2026-08-07 — box at `affe0647`, one docs-only commit behind, cause unresolved; re-measure before quoting either state. Still real: **D1** extract the 435-line `DEPLOY_SCRIPT` from `deploy.yml` env (two-stage bootstrap) · SHA-in-`/health` (highest-leverage verify fix) · failure visibility. ⚠️ D15 re-scope: delivery becomes placement-parameterised (`saas_multitenancy.md` §5.1 condition 3) — one pipeline, N targets, never per-customer scripts. 🔴 all execution owner-gated. (2026-08-09) | ### Platform -| WS | Workstream | Owning spec | State | Next / notes | +| WS | Workstream | State | Owning spec · record | Gates · next (verified) | |---|---|---|---|---| -| WS-8 | **Agent architecture A0→C** (single runtime, manifests + `agent_defs`, generic declarative builder, Agent Workshop describe-to-create) | `agent_architecture.md` **§12.2** (the lettered tickets WS-8a…WS-8n) | 🟡 | A0's `approve_all` half done 2026-07-26. ~~"three states in one doc, see §5"~~ **repaired 2026-08-03** — §5 doc-remediation item 14 is closed (one A0 status; the F/G dependency split is written). **~60% of Phases A+B is unwired substrate — read §12.1 before dispatching anything from this row**, or an implementer will rebuild `manifest.py` / `declarative.py`, both of which are complete, documented and tested with zero production callers. ~~"Phase A unblocks D3's long-term form"~~ **struck — verified false in the direction that matters:** `config.json`-based instancing already ships via `AgentManifest.instance_key()` (`manifest.py:235`, live at `executor.py:917-937` and `routes/workspace.py:247-256`, with a `sharing` block on all six first-party agents), so **WS-14 is NOT waiting on WS-8 Phase A** (§12.5). D7's MAF-side MCP gap is now a ticket here — **WS-8c**. | -| WS-9 | **Memory tiers 3b/3c/4** (budgeted file-tier header, provenance markers, correction UX, supersession) | `memory_architecture.md` §9 (corrected 2026-08-01) | 🟡 Docs | 3a′ substrate shipped (migs 136–139). §6.7 correction UX is the highest-leverage UX item in the corpus. **Audited 2026-08-02 → NO-GO**: §9 gives acceptance for **3a′ only** (which is WS-10's, already shipped) — 3b/3c/4, the whole of WS-9, have none; §6.7 is experience prose with no endpoint, model or assertion; §6.5 ends "there are two honest paths… don't do both", an owner call presented as acceptance. Header still says `Draft / RFC · 2026-07-26` over a body stamped 2026-08-01 (R4). Paths are bare filenames whose line numbers have moved (`routes/memory.py` gate is now `_authorize_scope` :128-167; `_tool_injection.py:488-493` moved to `acb_skills/addendum.py` in WS-23 S3). **Verified substrate:** `MemoryClient` has search/add/get_all/delete and **no `update`**; the API has no PUT/PATCH; `/memory` already does list + semantic search + delete + clear-all (§5.5 understates it) but has **no edit, no provenance**, and hardcodes one of the **five** scope shapes (`` · `prefs:` · `room:` · `agent:` · `org:global`). No provenance/supersession fields exist anywhere. **NOT owner-gated** — the gate logic is testable against a fake with Mem0 disabled (41 tests, 0.58s); the real trap is inverted: **this box's `.env` already has Mem0 enabled, and `tests/unit/test_memory_integration.py` HANGS (measured exit 124); assume `test_memory_e2e.py` does too — name test files, never `tests/unit/`.** **D4 constraint:** `orchestrator/agents.py:520-534` reads only the user scope, so correcting an `org:global` fact would show fixed in the UI and change nothing on that path — PR-1 must restrict to ``/`prefs:`/`agent:` or say so. **Slice when specced (3c-0, AGENT-SAFE):** `PATCH /memory/{scope}/{memory_id}` reusing `_authorize_scope(write=True)` + the 404-not-403 membership probe at `memory.py:237-240`; `MemoryClient.update`; provenance in **Mem0's own metadata** (`corrected_by`/`corrected_at`/`supersedes`) — no new table; PATCH in the Next proxy; inline edit + compartment selector on `/memory`. **Scope creep to cut:** §6.1 instance-keying is WS-14's and the 3a′ remainder is WS-10's — this row should stop claiming both. | -| WS-10 | **Multiplayer remainder** — S1 `subject:` compartments · floor-control re-decision · `prefs`/`user` backfill | `docs/multiplayer/memory-clearance.md` §7 + §7.1 (**the dispatchable slice**) · `docs/multiplayer/README.md` §8 (room-side index) — *`specs/multiplayer_prior_art_qm_2026-08.md` is reference-only per §4 and supplies acceptance for nothing* | 🟡 Docs → S1 | **Steer is SHIPPED — struck from this row's title** (`15c8933f`, ancestor of `main`: `orchestrator/steer.py::route_turn` → DROP/ENGAGE/ABORT/STEER, durable `cc:steer:` signals, `202 {"steered": true}` stand-down, `409 steer_outside_run_floor`, plus the two-layer supersede guard; `tests/unit/test_steer_routing.py` + `test_supersede_guard.py` green). **Audited 2026-08-01 → NO-GO on 5 of 7 contract points; §5-style remediation applied 2026-08-02** (both docs re-headered "verified against code on 2026-08-02", §3.5's 5 stale anchors fixed, gate labels added, verification blocks added). **That remediation was then independently verified and returned FAIL; repair round 1 landed the same day.** The P0 was the remediation's own new claim that `mark_active(reset=True)` raises `SupersedeRefused` — **it does not**: `mark_active` (`stream_relay.py:343-405`) deletes the stream at `:377` with no ownership check, and the only `raise` is at `:895` inside **`run_detached`** (`:823`), before it calls `mark_active` at `:909`. So the guard covers `run_detached`'s callers, **not** the destructive statement; both docs now say so, and README §12.3 carries an anchor grep that shows the line ordering. Six smaller defects fixed with it: `feature:memory` is `permissions.py:68` (not `:70`); §7.1.3 dw1 said "member" where §7.1.5 allows members (now **non-member**); dw5's `409` now matches its own precedent's **400** (`routes/rooms.py:533-538`); the slug grammar no longer claims `_clean_slug` (which forbids `.`, allows a leading `-`/`_` and unicode alnum) — it is `_SEGMENT_RE`'s shape plus a 64-char bound; `subject_ref` now reads as a **compartment scope key** everywhere (§3.2/§3.4/§4.1/§7.1.8), not an entity ref; and three already-green done-whens (§7.1.1 dw2/dw3, §7.1.5's miscounted row) were **replaced with criteria that require the work**, not merely labelled. Residual recorded, not built: moving the ownership check into `mark_active` would make it an invariant over the statement — no ticket minted for it here. **The row is now three things, and only one is work:** ① **`subject:` compartments = WS-10 S1, the dispatchable slice.** It is the one item with real query-layer acceptance (`memory-clearance.md` §7, kept verbatim) — it was NO-GO only because the surface it presumes was unspecified. **`memory-clearance.md` §7.1 now specifies it** (create/add-member endpoints and their gating, the `subject_ref` writer folded into the existing `PATCH /sessions/{id}/room`, the `_authorize_scope` rule, `audience='team'` → the shipped `org_group`, and a testable `sensitivity='restricted'` = *existence is confidential*, 404-not-403). Every decision there is marked `DECISION (agent-proposed, owner may overrule)` — **AGENT-SAFE once §7.1 is accepted**: dispatch after the owner reads it, or overrule and re-dispatch. (The owning spec's own Gate cell now carries that qualifier too — it read an unqualified "AGENT-SAFE" until 2026-08-02, and by this board's Authority rule the owning spec out-ranks this row for *what to build and how*, so the weakest of the three preconditions was the one that would have won.) **Repair round 2 (2026-08-02) — adversarial review returned REQUEST-CHANGES with no P0 and five P1s; all repaired in the same change.** The one that mattered: §7.1.4 specified the clearance cap as *"computed the way `_capability_cap` (`rooms.py:191`) already computes the credential cap"* — but `_capability_cap` **drops `group:` and `org` subjects by design** (`:207`, and short-circuits empty at `:208-212`, its own comment at `:209-211` saying so), so an implementer following that pointer would have turned the intersection into a **union** for exactly the rooms where a leak is widest: `[owner@x, group:sales]` bound to a restricted subject would come back with an empty cap, read as "no non-member participants", and admit the compartment to `Clearance.read` for forty people — while done-when 4 ("a non-member participant") passed green against two email addresses. §7.1.4 now names the site (`_subject_clearance_cap` beside `_capability_cap` in `routes/rooms.py`, consumed at the tree's only `resolve_clearance` call site, `routes/agent.py:1768-1774`), requires participants to be **expanded before** the intersection through one factored-out helper (`acb_auth.access.expand_session_subjects`, lifted out of `resolve_session_access` `:343-434` which already does the `group:`/`org` expansion at `:330-340`), and requires that expansion to **fail closed** — the opposite posture to `resolve_session_access`'s deliberate fail-open at `:417-426`, stated as such so nobody "fixes" it back. Done-when 4 is now four parts that cannot be satisfied without the `group:` and `org` cases. The other four P1s: the prior-art doc's QM-1 state cell still read "designed, unbuilt" for shipped steer (and QM-2 "✖" for built-but-off S4) while two other files in the same change said built; README §2's anchor table was **7 wrong of 8** under a "verified" header (fixed + caveat added, plus four stale repeats outside the table); §5.2 cited `test_reset_wipes_the_event_log` as demonstrating the `mark_active` bypass when that test seeds no `cc:runactor:` and so **cannot distinguish the two states** (README now says no test demonstrates it and describes the one that would); and §7.1.4 done-when 6 asserted a `422` on unknown `PATCH` keys that shipped code does not produce — `RoomPatch` (`routes/rooms.py:81-84`) is a plain `BaseModel`, no `extra="forbid"` anywhere in the gateway, verified against the repo interpreter (pydantic 2.13.4) — so it now pins the *real* behaviour and closing the model is filed as its own ticket in §7.1.9 rather than smuggled into this slice. ② **Floor control = OWNER-GATE, registered in §6 by name.** Per QM-1 steer dissolved most of the problem the baton was invented for; README §8 Phase 2 says whether the five modes still earn their place is *"pending the owner's re-decision"*. No acceptance is written for it on purpose — writing one would make an owner call look like queued work. ③ **`prefs`/`user` backfill** — classifier + **dry-run report** is AGENT-SAFE; **applying it is OWNER-GATE**, registered in §6 (mutates live Mem0). Verified: nothing writes a `prefs:` key anywhere today, so `prefs:` is permanently empty until this runs. **Two prior-art corrections (2026-08-02):** QM-3's *"rather than one `acting_identity`"* was factually wrong — there is no such column and never was (mig 138 `:26` rejects it explicitly), so QM-3 is net-new work with zero acceptance and maps to **WS-2 / WS-1, not here**; and the R2 phase-ID collision is resolved — the prior-art doc called `subject:` compartments "3b" while the owning spec puts them in the **3a remainder**, so the owning spec's ID wins and the board calls the slice **S1**. QM-5 (tenure narrows the model, not just the viewer) is a **real gap with an undone design**: viewer half built (mig 138 `:97-98` → `rooms.py:277-292` → `chat.py:314-316`), model half not (`_get_messages(thread_id, _hist_uid, …)` at `routes/agent.py:1947-1956` narrows by the acting caller only) — but README §6.5 says the two mechanisms are *"worth comparing before building either"*, which is a decision to record, not acceptance. | -| WS-11 | **Workflows Slice 3** (template gallery, fan-in/join, loops); Slice 4 after WS-4 | `workflows_app.md` **§8.3** (re-scoped truth pass 2026-08-03) | 🟢 | Slice 3 = **8.3a** template gallery · **8.3b** fan-in/join · **8.3c** loops (**owner-approved 2026-08-03**, D10 — §11's standing anti-n8n rule R1 governs the node *catalog*, not the control-flow *vocabulary*, and must not be cited as a blocker on loops). All three AGENT-SAFE. **~1/3 of this row was struck:** "describe→generate→refine full-graph authoring" **shipped as F14** (`39b1e17a`) — dispatching it would have sent an implementer to rebuild the live `POST /workflows/{id}/copilot`; "parallel fan-out" also ships (`engine/graph.py:17`, MAF's superstep scheduler routes it), so the real remaining content is fan-**in** plus loops. Templates are greenfield — nothing exists. **8.3b and 8.3c each invert a pinned test** (`test_fan_in_rejected_v1`, `tests/unit/test_workflows_engine.py:155`; `test_cycle_rejected`, `:148`) — leave either asserting rejection and the ticket closes **green having built nothing**. Template *content* is an owner input; the report-digest template is **WS-15's** artifact, not this row's. Slice 4 stays blocked on **BO-20b slice 2 → BO-20c → (BO-20d, BO-20e) + BO-7** (§8.4), and its activation rides the OWNER-GATE `INGESTION_CONSUMER` flip. | -| WS-12 | **Framework uplift** | `multi_agent_orchestration.md` **Phase 4 only** (D6 banner 2026-08-01; shrunk 2026-08-03) | 🟡 Ph4 | **Audited NO-GO on all seven contract points; shrunk to Phase 4 only on 2026-08-03, not closed.** Ph0 shipped. **Ph1 struck** — 1.1 shipped as *progressive disclosure* (`93b93a08`, #191); 1.2 moot (`technical-project-planner` exists in neither `_AGENT_REGISTRY` nor `apps/agents/`); 1.3 delivered by **WS-23**. Ph2–3 superseded by the shipped Workflows app (D6). **Ph5 struck** — 5.2 shipped as multiplayer rooms *without* the orchestrations package, so it never depended on Phase 4; **5.1 is reassigned to WS-11**. **Ph4 is the genuinely undone part** — all four §5.5 shims re-verified in-tree 2026-08-03. **Drift correction: Phase 4 drags ONE SDK major, not two** — `uv.lock` and the repo `.venv` both carry `openai 2.38.0`, so the billed `openai 1.99 → 2.x` major already landed independently; only `github-copilot-sdk 0.1.32 → 1.0.2` remains. **0 PRs dispatchable today:** 4.0's target choice (minimal- vs full-bump) is **OWNER-GATE**; 4.1 (resolution proof in an isolated throwaway venv, evidence-only, AGENT-SAFE — it must never mutate `/.venv` or `uv.lock`) is what unblocks it. | -| WS-23 | **Skills registry + per-agent skill toggles** (added 2026-08-01) | `specs/skills_registry.md` | 🟡 S1+S2 built | **S1+S2 shipped pending review 2026-08-01**. S1: `acb_skills/skill_families.py` registry + measured token-cost catalog, `GET /integrations/skills`, Integrations → Skills tab, drift test; measured baseline ≈19.3k tokens (core floor ≈15.1k). S2: `agent_skill_setting` table (override-shape provenance), `GET/PUT /agent/{name}/skills` (`admin:access:manage`; core/apps → 422), **intersection-only** enforcement in `_resolve_injected_scope` (no rows ⇒ byte-identical — regression-tested), Agents-page Skills panel with live token meter; decision note in spec §2: workflows toggle honored at its append site, Custom-App grants NOT toggle-governed. **S3 generation half + scope-out shipped pending review 2026-08-01**: addendum prose now GENERATED from family-tagged section registries in `acb_skills/addendum.py` (one renderer for injection AND catalog cost measurement; tool set byte-identical, text identical except the `App()Ellipsis` f-string fix); evidence-based scope-out in `specs/skills_scope_out.md` (GENERAL = core/memory/workflows/apps; SPECIALISED = history→orchestrator, coding→apis-config); `DEFAULT_PROFILE` + `SKILLS_FAIL_CLOSED` switch prepared and **shipped OFF**. Measured: all-families 19.3k → DEFAULT_PROFILE 17.8k → core floor 15.4k tokens — the ≤2k email target needs a core-floor diet, not toggles. Remaining: **OWNER-GATE** the `SKILLS_FAIL_CLOSED=1` flip (review dynamic agents first, `skills_scope_out.md` §4). Per-instance profiles defer to Centers C; manifest side lands with WS-8. **S4 core-floor diet BUILT 2026-08-01** (`skills_scope_out.md` §7): *Half A* `acb_skills/skill_index.py` — addendum becomes one line per family + `recall_notes("skills/.md")`, bodies materialized to `agent-data/skills/` content-hash-idempotently after the blob rehydrate, byte-preserved via the new `addendum.rendered_parts()`, index inside the prompt-cache-stable prefix, **`SKILLS_INDEX_ONLY` ships OFF**; *Half B* schema trim, live, **zero call-contract change** (pinned in `tests/unit/test_tool_schema_diet.py`). Measured: addendum 5,697 → **570**, core-floor schemas 9,998 → **8,510**, full surface 19,259 → **12,644**, email-assistant-recommended 17,757 → **11,337**. **≤2k still NOT met and unreachable by trimming** (22 schemas cost 1,252 tokens with descriptions deleted) — progressive tool disclosure + an `emit_generative_ui` schema pointer are designed and costed in `skills_scope_out.md` §7.5, **deliberately not built**. Remaining: **OWNER-GATE** the `SKILLS_INDEX_ONLY=1` flip. | +| WS-8 | **Agent architecture A0→C** | 🟡 | `agent_architecture.md` §12.2 (WS-8a…n) · board record 2026-08-09 | A0 `approve_all` half done 2026-07-26. ⚠️ **Read §12.1 before dispatching**: ~60% of Phases A+B exists as complete-but-unwired substrate (`manifest.py`, `declarative.py` — documented, tested, zero production callers); an uninformed implementer rebuilds it. **WS-8c** = the MAF-side MCP injection silent no-op (D7), AGENT-SAFE. WS-14 does **not** wait on Phase A (D3 amendment). (2026-08-03) | +| WS-9 | **Memory tiers 3b/3c/4** | 🟡 Docs | `memory_architecture.md` §9 · board record 2026-08-09 | 3a′ substrate shipped (migs 136–139). **Ownership settled 2026-08-09: the 3a′ remainder (`subject:` compartments) is WS-10's S1; this row owns 3b/3c/4 only.** Audited NO-GO — §9 carries acceptance for 3a′ alone. Ready when specced: **3c-0** correction PATCH slice (AGENT-SAFE; shape in the record). Not owner-gated. ⚠️ never run `tests/unit/` as a directory here — `test_memory_integration.py` hangs. (2026-08-02) | +| WS-10 | **Multiplayer remainder** — S1 `subject:` compartments · floor re-decision · backfill | 🟡 S1 | `docs/multiplayer/memory-clearance.md` §7/§7.1 · board record 2026-08-09 | Steer shipped; two verification/repair rounds closed 2026-08-02. The work: **S1 `subject:` compartments** (AGENT-SAFE once §7.1 accepted). 🔴 floor-control re-decision (§6 — an agent must refuse that part by name) · 🔴 `prefs`/`user` backfill **APPLY** (§6; classifier + dry-run report are AGENT-SAFE and the whole mandate). ⚠️ WS-29 coda: `org:global` scope is deployment-global today and must become tenant-scoped — coordinate S1 with MT-1c/D17; do not mint a sixth scope shape (`saas_multitenancy.md` §1.9). (2026-08-02) | +| WS-11 | **Workflows Slice 3** (gallery, fan-in/join, loops) | 🟢 | `workflows_app.md` §8.3 · board record 2026-08-09 | Slice 3 = **8.3a** gallery · **8.3b** fan-in/join · **8.3c** loops (owner-approved, D10.2; R1 governs the node *catalog*, not control flow). 8.3b/8.3c each must **invert a pinned test** (`test_fan_in_rejected_v1`, `test_cycle_rejected`) — leave either standing and the ticket closes green having built nothing. Template *content* is an owner input; the report-digest template belongs to WS-15. Slice 4 after BO-20b2 → c → (d, e) + 🔴 `INGESTION_CONSUMER` flip; its sandbox-dependent parts follow MT-0c-2's trigger (D16) — the old bare "BO-7" dependency is restated. (2026-08-03) | +| WS-12 | **Framework uplift** | 🟡 Ph4 | `multi_agent_orchestration.md` **Phase 4 only** (D6) · board record 2026-08-09 | Ph0 shipped; Ph1 struck; Ph2–3 superseded (D6); Ph5 struck. One SDK major remains: `github-copilot-sdk 0.1.32 → 1.0.2` (`openai 2.38.0` already in-tree). **0 dispatchable PRs**: 🔴 Phase 4.0 target choice + 🔴 Phase 4.6 recorded human soak (§6). Phase 4.1 throwaway-venv resolution evidence is AGENT-SAFE and must never mutate `.venv`/`uv.lock`. (2026-08-03) | +| WS-23 | **Skills registry + per-agent toggles** *(added 2026-08-01)* | 🟡 built | `specs/skills_registry.md` · board record 2026-08-09 | S1–S4 shipped pending review: registry + measured catalog, per-agent toggles (intersection-only, core floor non-toggleable), scope-out proposal, index diet (full surface 19,259 → 12,644 tokens). The ≤2k target is **unreachable by trimming** — §7.5 progressive disclosure is designed, costed, and deliberately unbuilt. 🔴 `SKILLS_FAIL_CLOSED`, `SKILLS_INDEX_ONLY` flips (§6). (2026-08-01) | -### Product — Centers (`department_centers.md` §3) +### Product — Centers (`department_centers.md` §3 · combined board record 2026-08-09 there) -| WS | Workstream | State | Next / notes | -|---|---|---|---| -| WS-13 | **Centers B — groups become real** (groups admin UI, seed six groups, People directory read view) | 🟡 | Groups admin UI + six-group seed **built 2026-08-01, pending owner review** (`routes/admin/groups.py`, `/settings/groups`, seed migration; see `department_centers.md` Phase B update). People directory read view still open. The unlock for everything below. Single owner: Centers B (groups spec §6 step 5 and org_access Phase 2 are mirrors). ✅ **FIXED 2026-08-03 (`ws-13-centers-feature-vocabulary`): the feature-vocabulary half of this row is closed.** `acb_auth.permissions.FEATURES` now carries the six `center.*` slugs in migration-140 sort order, two invariant tests in `tests/unit/test_org_access_control.py` now fail loudly if one goes missing — `::test_every_center_has_a_feature_slug` (anchored on a literal `EXPECTED_CENTER_SLUGS`, because the first version *derived* the expectation from `CENTER_GROUP_SLUGS` and therefore went vacuous when that tuple was emptied) and `::test_centers_registry_matches_the_feature_vocabulary` (**parses** `lib/centers.ts` and pins it both ways to `FEATURES`, so the documented "add a Center" recipe can no longer reproduce this bug with a green suite). `department_centers.md` §2 now carries the five-place registration checklist. And the admin role editor groups its chips by `feature_catalog.category` with a real "Centers" heading (`settings/roles/page.tsx`, `Feature.category` union widened in `members/types.ts`). No migration was needed — 140 already widened the CHECK. **Separate, still open:** `workbench/control_plane/src/app/page.tsx:11-12` renders `NAV_SECTIONS` with **no** access filter, so the home grid still advertises every pane (Centers included) to every viewer while the sidebar correctly hides them — recorded in `workbench/AGENTS.md`. The finding as originally written, for the record: **Centers were unreachable by ANYONE, including the owner.** `/auth/me` returns `"features": list(access.allowed_features())` (`routes/admin/me.py:84`), and `allowed_features()` iterates the **hardcoded Python tuple** `acb_auth.permissions.FEATURES` (`:64-81` as the tuple then stood; `:73-101` after the fix) — sixteen slugs, **no `center.*` entry**. The frontend gates on exactly those slugs: `lib/access.ts:66` maps `/centers/` → `c.feature` (= `center.sales`…), `canUseFeature` is `access.features.includes(slug)` (`:118`), and `visibleSections` drops any pane whose feature is absent — **and drops the whole section when it empties** (`lib/nav.ts:229-233`). Net effect: the Centers section renders in neither nav, and typing `/centers/sales` hits `AccessGate`'s "You don't have access to this". Migration `140_center_features.sql` **does** seed six `feature_catalog` rows, but `allowed_features()` never reads that table — so migration 140's own comment ("owners and admins see all Centers via their `feature:*` baseline") is **false as written**: an owner holding `*` still gets an empty set, because the wildcard is only ever evaluated against the sixteen literals. The fix taken was the vocabulary one (`FEATURES` gains the Center slugs) plus the invariant test; making `allowed_features()` read `feature_catalog` was rejected — `permissions.py` is pure and does no I/O by design. | -| WS-14 | **Centers C — scoping deepens** (tasks team slice, shared mailboxes, team-instanced agents, per-Center approvals) | 🟢 **unblocked 2026-08-03 (D12)** | **The blocker is answered.** This row read "blocked on what makes a project a team's project" for weeks; **D12** answers it: **a project belongs to a team when an explicit grant row carries a `group:` subject** — *not* derived from assignees, *not* an owning column. Both alternatives and why they were rejected are recorded in `specs/tenancy_and_visibility.md` §4 (`DECISION (owner-answered 2026-08-03)`); §5's gap table is the app-by-app map, and §3.2 is binding on the mechanism — **extend the existing `email \| group: \| org` subject vocabulary, do not invent a second one.** ⚠️ **The primitive is narrower than previously claimed:** only **rooms** honour `group:` today (`routes/rooms.py::_valid_subject` `:100-111`, expanded at `gateway/rooms.py:181-199` — **corrected 2026-08-03 from the stale `:163-179`**, which is the `chat_session` SELECT, not the group join; the `SELECT g.slug` is at `:192`). `app_grants` does **not** — `routes/apps/grants.py::is_valid_subject` (`:68-85`) is `email \| agent: \| agents:*` and explicitly **rejects `org`** (`:77`); the "identical to grants.is_valid_subject" docstring at `rooms.py:103` is false and should be corrected by whichever ticket touches it first. **What it can now build, in order:** (1) the tasks team slice — a project grant table + a read path unioning "mine" with "granted to a group I'm in" (blast radius: 27 `user_id` predicates in `routes/tasks/items.py`); (2) the `dynamic_agents` sharing columns per D3 — re-verified 2026-08-03, `15_dynamic_agents.sql:7-20` has **no** owner/visibility/sharing column and a repo-wide grep finds none, so this migration is genuinely WS-14's, at the **next free number resolved at build time** (R1); (3) `group:` on the Custom-Apps grant subject, the cheapest conversion since `apps.visibility` already carries the three tiers. Shared mailboxes stay `email_app_master_plan.md`'s implementation, sequenced here (D5). **Not blocked on WS-8 Phase A** (D3 amendment) and **not** waiting on WS-13's UI — but note WS-13's new finding: the Center *surfaces* are currently unreachable, so scoping work will need that one-line feature-vocabulary fix to be demonstrable. ⚠️ **Re-audited 2026-08-03 → the row was NOT dispatchable as written; `department_centers.md` §3 Phase C was rewritten and this row now points at four lettered bullets, only two of which are work.** **C1 tasks team slice — 🟢 AGENT-SAFE**, and it is the whole of the near-term value: grant table decided (`tenancy_and_visibility.md` §4.1 = **D13**, `gtd_project_grant`, agent-proposed and overrulable, **no `role` column**), union read path, migration at the next free number resolved at build time, and a **404-not-403** assertion for the non-member (the shipped convention — `routes/memory.py:237-240`). ✅ **Repaired 2026-08-03** after review found C1's acceptance could go green with **no way to create a grant**: done-when 1 now names a caller-reachable creation path (`POST`/`DELETE /tasks/projects/{project_id}/grants` on the shipped `/tasks` router, `feature:tasks` + project ownership, 404-not-403 per `routes/apps/_common.py:459-475`, module wired into `routes/tasks/__init__.py`), done-when 2 requires the grant under test to be created **through that route** rather than by a fixture `INSERT`, and done-when 5 names the shared validator's home (`packages/acb_auth/acb_auth/permissions.py`) — it previously named no module and no shared home existed. **C2 shared mailboxes — 🟢 AGENT-SAFE for the doc action, build blocked, no owner in fact** (see §4; the bullet's old "NOT DISPATCHABLE" was a third gate token and was mapped onto the contract's two). **C3 team-instanced agents — 🟢 AGENT-SAFE but narrow:** the seven agents the old bullet named do not exist, and `t:`'s *writer already ships* (`acb_skills/manifest.py:242-246`), so the slice is the `dynamic_agents` columns (shape per `agent-kinds.md` §3, `:143-155`; **pre-provisioning — the columns are intentionally unread, per D3, and wiring a consumer is out of scope**) plus reconciling `agent-kinds.md` §6 against three shipped `config.json` files — **changing any existing agent's `instancing` is a silent memory/blob re-partition and is out of scope.** **C4 per-Center approvals — 🔴 OWNER-DECISION** (org_access Q2 open; `pending_actions` has no member/group/Center column). | -| **WS-14a** | **Tenancy TV-1 — the three `org_group` slug-only joins** *(minted 2026-08-03)* | 🟢 **AGENT-SAFE · 1 small PR** | Owning spec: **`specs/tenancy_and_visibility.md` §2**, which passes all seven contract points and had **no board row** until now — §4 assigned it to a spec, and the dispatch loop selects from §2, so the corpus's most dispatch-ready ticket was undispatchable. `org_group` is joined on **slug alone** at three sites; slug is unique only *within* an org (`UNIQUE (organization_id, slug)`, `138_…sql:49`), and **two of the three sit inside the session-authority intersection**, where a too-wide group *widens* access. Nothing leaks today (D11: one org), but these are wrong within one org too, which is why they survive D11. **Anchors, re-verified 2026-08-03 — the previously-published ones were wrong at `520476ab` and are corrected in the spec:** (a) `apps/services/gateway/gateway/rooms.py:181-199`, the `SELECT g.slug` at `:192` *(was `:170-179` = `if row is None` + the participant fetch)*; (b) `:368-403`, `SESSION_VISIBLE_SQL` opening at `:368` with the slug join at `:377` *(was `:332-340` = the tail of `resolve_room_access`'s return)*; (c) `packages/acb_auth/acb_auth/access.py:330-336`, `_GROUP_MEMBER_SQL` — **correct, unchanged**. ⚠️ **The spec's own "verified red" requirement was unsatisfiable and was repaired in the same pass:** §7 named `tests/unit/test_session_authority.py` and `tests/unit/test_rooms.py` as the extension point, and both open with `pytest.mark.skipif(not _db_ready(), …)` (`:33-51` and `:33-52`), so a fixture added there **skips green** with no Postgres. §2 done-when 2 now attaches red-first to a genuinely hermetic string assertion over the three queries (which requires lifting anchor a's inline SQL to a module constant — that extraction is part of the ticket), and done-when 3 requires quoting a `-v`/`-rs` run showing the DB-backed fixture `passed`, never `skipped`. Numbered **14a** rather than a fresh WS-n because it is the `org_group`-join half of the same subject-vocabulary surface WS-14 generalises; the two are independent PRs and either may land first. | -| WS-15 | **Centers D — dashboards + Company Center** (Center dashboards, personal dashboard, weekly digest workflows, orchestrator org-memory fix per D4) | 🟡 WS-13 | Digest workflows double as `workflows_app.md` G1 launch metric — one artifact, both scorecards. | -| WS-16 | **Centers E — AI budgets** (per-member caps at the LLM choke points; per-room degrade later) | 🟡 WS-6 | Subjects per D2. | +| WS | Workstream | State | Owning spec · record | Gates · next (verified) | +|---|---|---|---|---| +| WS-13 | **Centers B — groups become real** | 🟡 review | `department_centers.md` Phase B | Groups admin UI + six-group seed built 2026-08-01, **pending owner review**; `center.*` feature vocabulary shipped 2026-08-03. ~~People directory read view open~~ **closed by WS-28b** (2026-08-06). ~~"nav renders with no access filter" / "catalog-read was rejected"~~ **inverted by merged #389** (`747b65af` — the catalog, not a code mirror, decides). Residue: the owner review itself. (swept 2026-08-09) | +| WS-14 | **Centers C — scoping deepens** | 🟢 with ⚠️ | `department_centers.md` §3 C1–C4 | D12 answered the blocker (a project belongs to a team by an explicit `group:` grant). **C1 tasks team slice: ⚠️ RE-AUDIT before dispatch (flag added 2026-08-09)** — WS-27e's owner-directed one-store revision (D-PM-6: `pm_tasks` is THE task table; WS-27h retires `gtd_items`) may moot D13's `gtd_*`-local grant table; whichever way it lands, the subject grammar must not fork (§4, D13). C2 shared mailboxes: doc-action only, ownerless in fact (§4) · C3 team-instanced agents: narrow, columns intentionally unread · 🔴 C4 per-Center approvals decision (§6). (2026-08-03 · flag 2026-08-09) | +| WS-14a | **Tenancy TV-1 — the three `org_group` slug-only joins** *(minted 2026-08-03)* | ✅ absorbed | `specs/tenancy_and_visibility.md` §2 → **WS-29 MT-1i** | **Absorbed by WS-29 as MT-1i (2026-08-08) — do not dispatch from this row.** Code shipped on the WS-29 branch. Severity re-framed: under D15 the three joins **leak across tenants**, not merely misbehave within one. The open criterion — the two-org DB-backed fixture run `passed`, never `skipped` (§2 done-when 3) — travels with MT-1i. (2026-08-09) | +| WS-15 | **Centers D — dashboards + Company Center** | 🟡 WS-13 review | `department_centers.md` Phase D | Center dashboards, personal dashboard, weekly digest workflows (double as `workflows_app.md` G1 metric), D4 org-memory fix. Blocked only on WS-13's owner review now that WS-28b shipped the directory. | +| WS-16 | **Centers E — AI budgets** | 🟡 WS-6 | `department_centers.md` Phase E | Per-member caps at the LLM choke points (D2, D8). The chain is real: needs WS-6's **durable** attribution, which is HELD at WS-6b — do not dispatch expecting Redis-only records to suffice. ⚠️ MT-3's credit gate (D18 pricing) lands on the same choke points — design once, serve both. | -### Apps +### Multi-tenancy (SaaS) — `saas_multitenancy.md` -| WS | Workstream | Owning spec | State | Next / notes | +| WS | Workstream | State | Owning spec · record | Gates · next (verified) | |---|---|---|---|---| -| WS-17 | **Email completion** | `email_app_master_plan.md` | 🔴 owner calls | 3 pending owner decisions (kill-list batch, schedule-send go, contact-merge identity) + user-parked semantic search. Tier-1 hardening (§7) is 🟢 AGENT-SAFE and gates a second account. | -| WS-18 | **Tasks Phase 3** (Weekly Review, Waiting-For, ~~Horizons~~) | `task_manager_app.md` (corrected 2026-08-01) | 🟡 partial | **Audited 2026-08-02 → GO-NARROWED, and point 3 splits per view — the first row in four cycles to clear it.** ✅ **Waiting-For *surfacing* BUILT 2026-08-02, pending review** (`lib/waiting.ts` pure predicates + `WaitingForView.tsx` grouped by person + `ITEM_SELECT`/`GtdItemModel` now project the write-only mig-48 columns `expected_by`/`last_nudged_at`; **no migration — the substrate all shipped in mig 48**). Delegate now defaults `expected_by` from the item's own `due_at` (the in-app delegate path wrote NULL, so the headline §12 journey produced no flag at all). Fixed en route: a frozen `MOCK_NOW` (4 copies) that made the shipped overdue badge wrong by 33 days and growing, plus `mockData.ts`'s orphaned anchor. **🔴 Weekly Review = NO-GO** (§9.2 is a bare checkbox; `gtd_reviews.summary` is untyped JSONB — define the JSON contract + a per-movement done-when first). **🔴 Horizons = NO-GO and MIS-ASSIGNED** — no acceptance criterion exists anywhere, `gtd_horizons` has no link column to items/projects, and **the spec puts it in Phase 4, not 3**; strike it from this row's title or move it in the spec. **~~Open~~ CLOSED 2026-08-02 (follow-up):** `expected_by` now means exactly one thing — **an explicit human promise**. NULL ⇒ no promise was made, so the overdue line is the item's own `due_at` read **live** (nothing copied, nothing to go stale); non-NULL ⇒ a promise that stands independent of `due_at`. All four insert sites stopped deriving a copy (each was writing the item's own due date under another name), so the column is now written by exactly one path: `PATCH /tasks/items/{id}` with `expected_by` (ISO sets, `""` clears), which updates the open `gtd_waiting` row under a re-stated ownership `EXISTS`. Client judges `expectedBy ?? dueAt`. **No migration, no backfill** — rows delegated before this change keep their snapshot and stay judged on it; clearing one is a normal edit. **OWNER-GATE:** nudge drafting/sending (real-account email sends), delegation write-back to ClickUp (blocked on BO-1). **Drift found:** `gtd_reviews`/`gtd_horizons` have existed since mig 48 with zero gateway references — do NOT write a new migration for them; and the spec's `POST /tasks/projects/plan` was fiction (real: `POST /tasks/plan` + `/plan/apply`, shipped — only the ProjectPlanner UI is missing). **EVAL-LOCKED:** `propose()`/`propose_with_llm()` in `routes/tasks/ai.py`. | -| WS-19 | **Notes + meeting bot** (share-to-chat, ask-during-recording; bot Phase 2 error codes AGENT-SAFE) | `note_taker_app.md` + `meeting_bot_platform_plan.md` | 🟡 | **OWNER-GATE:** bot Phase 1 needs a human-created Google account (`notetaker@fracktal.in`); share-to-chat needs a Slack integration that doesn't exist (scope call). | -| WS-20 | **WhatsApp activation + remainder** (search UI 🟢 AGENT-SAFE; OCR needs a vision-tier decision; Odoo/Zoho-bound items blocked) | `whatsapp_message_manager.md` §11 (header fixed 2026-08-01) | 🟡 owner | **OWNER-GATE:** Meta env/app review, enrichment cost flags. | -| WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. | -| WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. | -| **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT + DEPLOYED** · ✅ **d read half BUILT + DEPLOYED** · ✅ **D2 = d-email BUILT 2026-08-07** (branch `ws-26d-email-timeline`, merged) · ✅ **D4 = d-write MERGED + DEPLOYED 2026-08-08 (PR #400, no migration; deploy 31217978773 log-verified)** · 🟢 **d-autolead dispatchable** · ✅ **D1 = f BUILT 2026-08-07 (branch `ws-26f-pipeline-truth`, NOT run against prod)** · ✅ **D3 = g BUILT 2026-08-07 (branch `ws-26g-reports`, no migration)** · 🟢 **DEMO CRITICAL PATH (owner-directed 2026-08-07, spec §9.0): ~~D1 f~~ (∥ D2 d-email) → ~~D3 g~~ → ~~D4 d-write~~ → D5 d-autolead** · 🟡 **h/i/e deferred past the demo; i spec-thin** | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **migration 144 applied on prod and `/crm` live as of 2026-08-06**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Measured 2026-08-06: mig 145 is applied on prod and the BACKFILL HAS RUN — 737 orgs / 1,189 contacts / 1,516 leads / 551 deals / 1,909 notes, zero dirty rows, zero unmatched owners; the §7.1 pre-flip curl confirmed the tenant honors RFC-1123 `If-Modified-Since` (304). The PUSH direction has still never run: `CRM_ZOHO_SYNC` ships OFF, nothing has ever written the live Zoho tenant, and enabling the flag or hand-running a push cycle against prod stays OWNER-GATE §6.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Deployed:** migrations 144 and 145 are applied on prod as of 2026-08-06 and `/crm` is live, so live rendering, drag persistence and deep links are owner-verifiable now) · **d** integrations — **audited 2026-08-06 GO-NARROWED and the narrowed slice is BUILT** (branch `ws-26d-agent-crm`): `apps/agents/agent-crm/` (`crm-assistant`, MAF, four READ tools over the existing `/crm` routes carrying the caller's `X-User-Email`, read-only enforced at the transport by a GET-only method allowlist) registered in `_KNOWN_AGENTS` + `_AGENT_REGISTRY` + `agent_registry.json`, plus `"crm"` added to the WhatsApp `_KNOWN_SYSTEMS` allowlist **parse-only** (nothing writes `wa_contacts.entity_ref`, the `crm` context block stays `None`, both pinned by test). **The three held-back items are now DISPATCHABLE — their doc blockers (B3/B4/B5/B7) were closed 2026-08-06 in `crm_app.md` §9.1-§9.3, every anchor read off `origin/main` rather than recalled:** **WS-26d-email** (the timeline join is CALLER-scoped, never record-scoped — it reuses the email app's `_account_scope` predicate, copied into `routes/crm/` rather than imported per D-CRM-4, joins by thread not message, inbound `from_address` only, and needs a new address index at the next free migration number) · **WS-26d-autolead** (hook = `routes/email/scheduler_hooks.py::process_new_mail`, the one seam scheduler+manual+webhook all funnel through; the per-message rules loop was considered and REJECTED because a classifier outage there double-fires and history backfills never reach it; unknown-sender test mirrors `_maybe_block_cold`, colleague suppression via `is_own_mail`) · **WS-26d-write — BUILT 2026-08-08** (branch `ws-26d-write`, **no migration**: every route the four tools call already existed). `request_confirmation` awaited at the top of each tool before any mutating request is built, fail-closed, and the `non_interactive_default` keyword is asserted ABSENT from the whole module rather than asserted != "approve" — pinning the argument rather than the value means a mutant does not get to pick a spelling the fence has not heard of. `_ALLOWED_METHODS` **widened, never deleted**: `{GET, POST, PATCH}`, still checked inside `_request`, with `DELETE`/`PUT` and any `_delete`/`_put` helper still absent, so the check that used to enforce "read-only" now enforces "never destroys". Path fence extended past `ast.JoinedStr` to `.format`/`%`/`+` (the re-review's P2) and — the part that makes it maintainable — **tested against synthetic sources one per idiom**, so "the fence went blind" is a red test rather than a silent gap. Two supervisor rulings landed as built: `update_deal_status` resolves the stage BY NAME inside the tool against `GET /crm/statuses/deal` (no UUID on the LLM surface; an unknown name returns the real lane names), and a lost-type target requires a `lost_reason` resolved the same way against `GET /crm/lost-reasons` — pre-empting the 422 the "close this as lost" demo beat would otherwise hit — with the vocabulary **only ever read, never created**. `create_lead` takes **no `owner_email` argument at all** (the route derives it from the acting user), deleting an LLM-filled identity field from the surface entirely. ⚠️ **One recorded departure from done-when 1**: the invariant asserted is *no mutation before consent*, not *no HTTP before consent* — two tools must read to describe honestly what they are about to do, and every pre-card call being a GET is itself pinned; the two tools that owe nothing to a pre-read are still held to literally zero calls. `@_annotate_risk` is the shared annotation convention and is NOT enforcement; the Action Broker covers the Zoho push, not the native write — the two fail in OPPOSITE directions and are not interchangeable. 76 new hermetic cases + `test_crm_agent.py` 87 → 143; ten mutants run red and reverted. **Built, not deployed.** The push-queue question is CLOSED — **D-CRM-9 (owner, 2026-08-06): agent-originated writes queue for Zoho exactly like human ones** (🟡 remainder; the flip stays OWNER-GATE) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. **Pipeline blueprint added 2026-08-07 (spec §5.1) after the owner's first live board session found lanes out of order and imported stages at 0% probability — root cause: the importer appends unseen Zoho stages past the seeds at probability 0, and 144's seeds renamed Zoho's defaults so name-match missed them; the admin API that could fix it is headless.** New tickets: **f** pipeline truth + settings UI — **BUILT 2026-08-07** (branch `ws-26f-pipeline-truth`, **no migration**: 144 already carried `position`, `probability`, `type`, `closed_at` and `expected_close_date`). `routes/crm/stage_metadata.py` = `POST /crm/import/zoho/stages`, floor `admin:access:manage`, **dry-run by default** and `?apply=true` to write; **>1 pipeline STOPS before the DB is opened** (D-CRM-11); f4's `closed_at` proxy is a direct UPDATE that bypasses `mark_dirty_on_update`, asserted statically against the statement text AND the module's call graph because the shared fake writes only what a SET clause names. Two settings readers on the Zoho read client with `ZohoScopeError`/`ZohoApiVersionError` so **no-scope, no-data and no-such-endpoint are three different reported outcomes** — and no-scope is the *expected* first answer, since the tenant's refresh token was never minted with `ZohoCRM.settings.*`. D-CRM-10's clamp landed in `admin.py::_validate_status` reading the ROW rather than the payload (a PATCH naming only `type`, or only `probability`, contradicts the rule only in combination with what is stored). `?tab=settings` is the headless-API fix and needs no Zoho token at all. **Nothing has been run against the tenant — dry run included.** · **g** forecast & funnel reports off `crm_status_changes` — **BUILT 2026-08-07** (branch `ws-26g-reports`, **no migration**: 144's `crm_status_changes` already carried every column). `routes/crm/reports.py` = four read-only endpoints (`/crm/reports/{pipeline,funnel,win-loss,owners}`) on the shared gated router, plus `?tab=reports`. **`WEIGHTED_SQL` lifted into `core.py`** (pipeline re-exports it) so the forecast formula has ONE definition, and `core.status_wire` absorbed two duplicate status projections instead of gaining a third. **The cross-language parity mechanism is minted here, not inherited** — the `priority.ts ⟷ priority.py` "precedent" is two hand-kept mirrors joined by a comment with no shared fixture anywhere: `tests/fixtures/crm_weighted_parity.json` (new directory) is read by BOTH pytest (through the emitted SQL, whose expression `_crm_fakes._WEIGHTED_SUM_RE` parses out of the statement text) and vitest (through `board.ts::weightedDeal`/`weightedRows`). ⚠️ **The funnel is defined against what the log RECORDS, not what its name suggests**: `crm_status_changes` logs transitions only, so all 551 imported deals have zero rows and "entered" is a VISITED-SET union (`from_status`, `to_status`, and the deal's current stage) — a `to_status` count reports an empty funnel for the whole live board; dwell is grouped by **`from_status`** (the stage being LEFT, the opposite key from the naive reading); the log stores NAMES, so a renamed lane orphans its history and orphans are REPORTED in `unmatched`, never dropped; and NULL `closed_at` — every imported closed deal until f4's owner-gated backfill runs — falls outside the trailing window, with the count reported so a 0% win rate is explicable rather than mysterious. The lost-reason breakdown carries a NAMED unattributed bucket: the earlier "complete by construction" claim is FALSE, since the importer bypasses both gates and `lost_reason_id` is `ON DELETE SET NULL`. **No `GROUP BY` is emitted** — the ticket asked for the choice to be stated: per-key aggregates in `get_pipeline`'s shape, because the weighted expression binds the lane's own default as `:stage_probability` and a grouped statement would stop BEING the expression the fixture and the fake read. 47 hermetic cases + 20 vitest + the 14-row shared fixture on both sides, 5 mutants red. **Two findings:** the `entity_type='deal'` mutant initially SURVIVED (the funnel keys through deal ids, so a lead row is excluded twice over) — closed with a row stamped `lead` against a DEAL's id, which is realistic precisely because `entity_id` has **no FK**; and `_crm_fakes`' `lower(col) = :param` reader matched a NULL column, which SQL never does, making the unassigned-owner bucket count rows its own aggregate never summed · **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views (🟡 spec-thin, audit-narrow first). **Demo path (2026-08-07, spec §9.0): full chain + all gates intact, tickets re-sequenced not thinned; f gained f4 — imported won/lost deals have no `closed_at` (importer never stamps it), backfilled from Zoho `Closing_Date` as a labeled proxy via a direct UPDATE that MUST bypass dirty-marking or ~500 no-op pushes queue for the live tenant.** | -| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e + i BUILT 2026-08-06 · j + k + l + m + n BUILT 2026-08-07** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g — and n is now BUILT (2026-08-07), so that dependency is SATISFIED**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end. g itself stays 🔴 OWNER-GATE for its own reasons (§6); this changes the prerequisite, not the gate · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store).. **j BUILT 2026-08-07** (mig `152_projects_notifications.sql`, `routes/projects/notifications.py`, the header bell + mention picker; 39 hermetic + 27 vitest cases, 10 mutants red): closes §11.2's second gap — *"assignment is silent"*. **Three rules decide who hears**, each the whole reason for a rule: never the actor (a bell that pings you about your own click gets muted, and a muted bell notifies nobody about anything); never an agent (they are handed work by the WS-27f dispatch sink, so a row addressed to one sits unread forever inflating a badge nobody can clear — enforced in Python AND by a CHECK); and **never somebody who cannot open it**, which is the security property: the notification carries the task's TITLE, so delivering one outside the grant closure leaks it and lands them on a 404. That third rule needed `resolve_visibility_for`, which answers for a THIRD PARTY — `resolve_visibility` reads a `UserContext` and the recipient of a mention has no request in flight — by reading the tables `/auth/me` reads and handing them to the **real** `build_access`, so wildcards and allow/deny overrides resolve identically on both paths. Notifications are written **inside the transaction**, not emitted on the bus: `emit` swallows failures by construction so a broken workflow can never fail a task edit, which is right for agent dispatch and wrong here. A mention is an **address, not a name**, because 148 dropped `UNIQUE(name)` — `@Priya` has no answer. **Two bugs found on the way in, both shipping at the time:** every project-task file upload was answering **422** (`ACTIVITY_TYPES` never learned 150's `attachment`; all 25 attachment tests passed because they monkeypatch `record_activity` — the seam under test was mocked out), fixed with two tests that READ the migrations; and `/projects?task=` did nothing, though the People Center has linked there since WS-28b. **WS-27b's UI BUILT 2026-08-07** (`components/ImportClickUp.tsx`, `lib/importPlan.ts`; 18 vitest cases, 3 mutants red): the importer shipped with WS-27b and was **unreachable from the product** — the empty state named "import a ClickUp workspace" and no control anywhere did it, so a new install stayed empty and the only route to real data was curl. Three steps, only the last of which writes: Preview (`/plan`, reads the tenant) → Dry run (`dry_run:true`, exercises the flattening) → Import. **The mapping stays the owner's act (D-PM-10)** and the UI is built so it stays one: the suggestion is pre-filled and shown beside its confidence IN WORDS ("a guess — check it", not `0.45`, because a bare number invites acceptance without looking), and a CONFIRMED mapping always beats a fresh suggestion so a re-run never silently re-maps a Space somebody already ruled on. Unmapped Spaces are a notice rather than a blocker, matching the importer. ⚠️ The gate is unchanged and is now exactly one click: building this was agent-safe, pressing Import is the owner's act, and no agent has run either endpoint against production. **The Tasks-app mirror path BUILT 2026-08-07** (`routes/projects/import_tasks.py`, `POST /import/from-tasks`; 43 hermetic cases, 7 mutants red) — owner-directed: *"just show up all the data that is there in the Tasks app inside the Projects app"*. A SECOND importer rather than a flag: the ClickUp one needs a live token, spends LLM budget and demands a Center mapping BEFORE anything is written, which is backwards for "show me my work today". This reads `gtd_projects`/`gtd_items` — the mirror already on the box — so no API call, no token, no model spend, and it works when the connector is stale. One named department, with the real ClickUp shape beneath it — **Space → Folder → List** rebuilt as projects, each carrying its own `clickup_id`/`clickup_kind`, so promoting a Space node is how the department split happens later. **Verified against a real Postgres** (full migration set + seeded mirror), which found THREE defects the hermetic suite could not: `gtd_projects.space_id` is LOCAL-only so the Space was always NULL; `pm_projects` has no `clickup_snapshot` column, so the first real click would have 500'd (this shipped in #393 and was fixed before anybody pressed it); and the preview under-counted 4 vs 7 because container nodes were only tallied on the write path. The root IS org-granted (same act as `create_node`, narrower than bulk-granting a tenant). Pinned: nothing outside `pm_*` is written; only `source <> 'LOCAL'` rows are read (a personal capture must not be published to a shared board); the provider's own status names are kept; an orphaned task is COUNTED, not dropped. **Mutation found a real gap**: deleting `dry_run` from the write guards left every test green, because on a FIRST dry run `root_id is None` blocks the write anyway — on a SECOND one the department exists and `dry_run` is the only protection. That is the realistic case (preview → import → preview again) and it now has its own test. **k BUILT 2026-08-07** (`routes/projects/filters.py`, `lib/grouping.ts`, `components/FilterBar.tsx`; 34 hermetic + 24 vitest cases, 13 mutants red, and 23 checks against a REAL Postgres) — closes §11.2's third gap, the one whose name was the sentence *"my open bugs in Ops, grouped by assignee"*. **ONE filter builder serves both the list endpoint and saved views**, because a saved view is nothing but a stored set of these filters and two implementations would drift — a *saved* view showing a different set than the same filters typed by hand is the one thing it may not do, so a test compares the two outputs directly. **Every filter is a WHERE clause**: paging happens in SQL, so a filter applied in Python after `LIMIT` returns short pages, and *"page 2 is empty but there are 40 more"* is a bug people work around for months instead of reporting. **`overdue` means past due AND still open** — a finished task with a past due date is done, and permanent red is how a board teaches people to ignore red. **An unknown category is a 422 naming the five real ones**, not an empty board a client reads as "this project is empty". Unknown config **keys** are DROPPED while a bad **value** falls back, because those are different failures: a view is a preference written by an older client, so refusing one over an unrecognised key would make every deploy a migration of everybody's saved views, whereas rendering still has to produce something. On the board, **a task with two assignees appears in BOTH columns** (it is both people's work; picking one hides it from the other, so the header counts tasks not group sizes), **empty status lanes are kept** while every other grouping drops empties (a missing "In progress" column reads as "no such state", not "nothing in progress"), and **dragging is offered only when the columns are statuses** — a drop writes the field the columns represent, and status is the one that is a plain PATCH, so a card that can be dragged into a column which cannot accept it and snaps back is worse than an honestly static column. `toConfig` is deliberately NOT `toQuery`: a query string carries only text so `toQuery` writes `"true"`, and `fromConfig` refuses a string where a toggle belongs, so a view built from query shape would come back with every toggle silently cleared. The project's **order-bearing board is withheld from the chips** — `tree.py` seeds one `board` view per project and it owns every `pm_view_task_positions` row, so offering its ✕ would offer to delete every hand-arranged position; saved views sit at position 300 above the seeded pair and `orderBearingView` is one function used by both the drag handler and the delete guard. **A FIFTH live bug, found the same way as the previous four:** `due_before` was `CAST(:due_before AS timestamptz)` with the raw query-string value — asyncpg infers the parameter's type FROM that cast and then refuses to encode a `str`, so the query never reached Postgres and **`?due_before=…` answered 500** while the hermetic fake, which agrees with whatever SQL it is handed, stayed green. `parse_when` parses on this side and binds a real `datetime`; garbage is a 422 that says what was expected; a naive value is read as UTC rather than inheriting the connection's TimeZone. Two tests — one on the bound value's TYPE, one refusing any `CAST(:param AS timestamp…)` anywhere in the builder — so the next `after=` filter written the obvious way fails in CI instead of in front of a member. **l BUILT 2026-08-07** (mig `155_projects_custom_fields.sql`, `routes/projects/custom_fields.py`, `lib/customFields.ts` + the panel block and the Fields dialog; 47 hermetic + 36 vitest cases, 23 mutants red, 35 checks against a REAL Postgres) — ClickUp's signature feature, and the shape §5's non-goals already recorded as the additive path: **definitions in a table, values denormalised onto the task as JSONB keyed by `field_key`**. NOT a row per (task, field): that is the textbook EAV answer and costs a join per field on every board paint — five fields across two hundred imported tasks is a thousand rows to gather and re-pivot, per render — whereas the JSONB column arrives with the task for free. **The cost is stated rather than discovered**: a value is not referentially tied to its definition, so the DATABASE cannot stop a key no definition owns from being written, and that guarantee moves into Python. Hence the validation IS the feature: an unknown key is a **422 not a silent drop** (a typo that no-ops looks exactly like a save); a patch **MERGES** (a client that knows three of five fields must not wipe the other two — and an older client, or an automation written before a field existed, is precisely that client); an explicit **null CLEARS the key** rather than storing a null, because it is the only way to express "unset this" and a stored null makes "never filled in" and "deliberately emptied" one value in every filter; and **`true` is not the number 1** — `isinstance(True, int)` is True in Python, so the coercers are one-per-type in a dispatch dict specifically so the boolean check can never drift below the number one. **The deliberate departure from Paca:** its research notes record "deleting a definition does not clean task data" as an accepted cost; it is NOT accepted here, because a key left in the JSONB is invisible — no definition means no column, no form row, no filter — until somebody recreates the name and every old value resurfaces carrying the new meaning. The cleared count is reported (R7/R8). Two things a definition may not change once values exist, both because the stored values would stop meaning what they say: **`field_key` is never editable** (it is the identity every value is filed under) and **`field_type` is a 409 naming the count** (text→select cannot re-interpret what is already written); dropping a select option some task holds is refused the same way, adding one is free, and the UI shows the derived key while the name is still being typed since that is the last moment anybody can change it. **Custom fields are REVERTIBLE, which is what makes them first-class:** `patch_task` folds a custom edit into the SAME `field_change` activity under `custom.` rather than inventing an activity type — `record_activity` refuses a type the CHECK does not list, the trap that made every attachment upload answer 422 — and revert restores by **merging onto what the task holds NOW**, never writing back the whole object, since another field may have been edited since and replacing the blob would silently undo that too. **A bug the ticket's own tests caught before it shipped:** `changedValues` compared a form's boolean against a `null` baseline, so a task with an unanswered checkbox sent `open:false` on EVERY save and posted a timeline entry for an edit nobody made — a checkbox has no "unset" state to render, so `false` is its baseline. **And a fence that was quietly a subset check:** `test_projects_routes` asserted a LIST of mounted paths, which catches the module somebody remembered to add a path for; it now also reads the package directory and asserts every module declaring a `@router` route is imported by `__init__.py` — the C1 trap where a missing import mounts nothing while every direct-call test still passes. Verified by deleting the import and watching it fail. **m BUILT 2026-08-07** (mig `156_projects_tags.sql`, `routes/projects/tags.py`, `lib/tags.ts` + the panel picker, the filter row, a `tag` board axis and the Tags dialog; 31 hermetic + 37 vitest cases, 16 mutants red, 30 checks against a REAL Postgres) — the row the research notes left open ON PURPOSE: `paca_pm_research_2026-08.md` row 13 REFUSED Paca's model (*"a bare jsonb string array on tasks. No registry, no colors, no rename/merge — the weakest part of Paca's model"*) and §5 shipped `pm_tasks.tags TEXT[]` in its place with a registry named as additive later. **The array STAYS** — a join table would add a row per tag per task and a join to every board paint, to buy referential integrity this app enforces in one place, whereas the array arrives with the task and its GIN index (146) already answers "tagged X". What the registry buys: **one spelling per tag** (identity is case-INSENSITIVE via a unique index over `lower(name)` so two racing requests cannot create both, display is case-PRESERVING, and the task's array stores the REGISTRY's form — which is what makes "filter by bug finds all of it" true rather than aspirational, and what lets a rename be one statement); **rename**; **merge**; and a **colour**. **Applying an unregistered tag REGISTERS it** — refusing would make tagging a two-step errand (leave the task, create the tag, come back), which is how tagging gets abandoned, and an abandoned tag set is worse than a messy one. **The cost is stated: every typo becomes a tag** — which is exactly why merge is here and is not optional, and why the picker SHOWS the moment of creation rather than minting silently. **A rename onto an existing name is a 409, not a silent merge**: different operations, different outcomes, and one of them destroys a tag — quietly doing the destructive one because the names collided is what stops people using a rename button. **A task carrying BOTH tags ends a merge with the target ONCE** — the case that is easy to get wrong, and getting it wrong leaves a duplicate that renders twice and survives the next merge too; `merged_tags` is pure so that case is asserted directly, and the rewrite runs over affected rows in Python rather than as an `array_replace`, which would leave the duplicate. **TWO tag filters** because both questions get asked and neither answers the other: `tags` is ANY (`&&`) and `tags_all` is ALL (`@>`); collapsing them would silently pick a meaning, and with three tags the answers differ by almost everything. On the board a task with three tags appears in three columns, the same honesty as two assignees appearing in both theirs. **The migration backfills AND rewrites data, deliberately and narrowly:** `tags` has been on `pm_tasks` since 146 and the import path writes them, so an empty registry beside a tagged corpus means the first rename finds nothing; the winning display form is **the spelling people actually use** (most frequent, ties broken deterministically — `min()` alone would canonicalise 400 "Bug" to a single stray "BUG"), and task arrays are then made to agree, only ever swapping one CASING of a tag for another, with the count reported in a NOTICE. **A bug the live run caught in that block:** the canonicalisation used the implicit-comma `FROM pm_tasks t, unnest(t.tags) ... LEFT JOIN` form, where the LEFT JOIN binds only to `unnest(...)` and `t` is not in scope for its ON clause — the migration aborted with "invalid reference to FROM-clause entry for table t". Rewritten as `CROSS JOIN LATERAL … WITH ORDINALITY`, which also fixed a second problem the first version would have shipped: `array_agg(DISTINCT ...)` sorts by its own expression, so every task's tag list would have come back alphabetised. **n BUILT 2026-08-07** (`routes/projects/bulk.py` → `POST /projects/tasks/bulk`, `lib/selection.ts`, `components/BulkBar.tsx` + checkboxes on board and list; 35 hermetic + 32 vitest cases, 16 mutants red, 34 checks against a REAL Postgres; **no migration**) — **the ticket §11.3 names as gating g, so that prerequisite is now satisfied.** It **reuses `automation.apply_task_patch` rather than growing a second writer**: that service exists because WS-27f needed an edit indistinguishable in validation from a human PATCH, and a bulk endpoint with its own field handling would be a third opinion about what a task edit is. Tags go through the same registry the panel uses, because the registry cannot be true if bulk is a second door into the array. **Status is named, never keyed — load-bearing here rather than stylistic**: a selection spans projects and a status id belongs to one root, so `status_id` for fifty tasks across three projects puts two thirds of them in a lane that is not theirs; it is a 422 with its OWN message, because it is the mistake somebody makes by copying a single-task PATCH body and "unknown field" would not explain why the thing that works on one task is refused on fifty. **Assignees and tags are ADD/REMOVE, never SET** — "assign these to Priya" means ALSO Priya, and a replace wipes every individual assignment the fifty tasks already carried; the destructive spelling is absent rather than discouraged. **Shape validated once, outcomes per task**, because those are different failures: an unsettable field is the same mistake for all fifty (422 before any write), while a status name present in one project and absent from another is a fact about THAT task — failing the batch for it makes a mixed selection unusable, which is precisely the selection somebody makes after an import. **An invisible task is SKIPPED, not an error** (R5: per-id reporting says exactly what a per-id 404 says, and aborting would let a caller probe for existence). **One transaction** — a re-triage that half happened is harder to recover from than one that did not, because nobody can tell which half. **Re-asserting a value is not a change** (`moved_people` is pure so the claim is asserted directly): fifty tasks already Priya's would each gain a timeline entry saying nothing, and a count nobody can trust is worse than no count. **ONE notification per person per batch, not one per task** — being handed fifty tasks should ring once and say fifty; fifty bells is a bell people turn off, which is WS-27j's own argument applied to the case that would have broken it. In the browser the selection is **pruned whenever the filter changes** (select forty, narrow to three, press Done must not act on thirty-seven nobody can see), a shift-click ranges over the board's ON-SCREEN order, a two-assignee card drawn in two columns counts once, and the outcome line names every category including the boring ones. **AND IT UNCOVERED A SHIPPED WS-27j BUG** (spec §11.12): `notifications.deliverable` probed only `project_clause('t.root_project_id')` while `core.task_visibility_clause` — whose docstring warns a second implementation "would drift the moment one is edited alone" — carries TWO ways in. So (1) anybody assigned work in a project they hold no grant on was judged undeliverable: they could OPEN the task, the assignment notified nobody, and the response told the assigner they could not see it — the silent assignment WS-27j exists to end, still open for the most common case in a grant-scoped app; and (2) scoping to `root_project_id` ALSO missed a grant made on a SUBPROJECT — the old test asserted that scoping on the argument that "probing `project_id` would miss a grant made on an ancestor", which is BACKWARDS, as a real-Postgres run showed: the closure is recursive and expands DOWNWARD. Fixed to use the shared clause; the test that encoded the wrong reasoning now records why it was wrong. **A fake collision the fix exposed:** `FakeDB` matched the rule-3 probe by substring, and the new clause embeds both `pm_task_assignees` and the closure's `UNION` — exactly the AUDIENCE branch's fingerprint — so `deliverable` got a list of assignees where it expected a visibility answer and four tests failed for a reason unrelated to the code under test. A fake that dispatches on substrings needs fingerprints that are SPECIFIC, not merely present | -| **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | ✅ **a + b BUILT 2026-08-06 · b-write BUILT 2026-08-07** · 🟢 c–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE).. **a BUILT 2026-08-06** (mig `148_people_key_shape.sql` + `scripts/import_hr_people.py`; 22 cases, 11 mutants red, 1 equivalent): `UNIQUE(name)` dropped, partial unique on `lower(email)`, status CHECK. **P-1 did not name its own consequence** — the HR importer upserts `ON CONFLICT (name)` and would have failed outright, so a `source_key` (`:`) carries the upsert instead, backfilled BEFORE the constraint is dropped while `name` is still distinct. **Neither new constraint may block a deploy** (main was bitten twice this month): a duplicate address is quarantined into a new `email_conflict` column with a deterministic winner rather than failing `CREATE UNIQUE INDEX`, and the status CHECK is added `NOT VALID` then validated in a guarded block, so an unanticipated legacy value leaves a NOTICE instead of stopping the deploy. ⚠️ `schema.generated.sql` NOT refreshed — needs a live DB; regenerate on the first deploy that applies 148. **b BUILT 2026-08-06** (mig `149_people.sql`, `routes/people/`, `src/app/people/`; 32 hermetic + 28 vitest cases, 11 mutants red): `/people` directory, person page with all four panels, and the People Center's "Directory & org chart" sub-app flipped live — closing WS-13's outstanding read view. **Its own feature slug**, not `feature:tasks`: a manager who needs the org chart should not be handed the personal GTD task manager to get it. The gate is new but the HR **projection is imported** from `tasks.core` and a test asserts the function's *identity*, since two answers to "may this caller see skills" are two answers waiting to drift. Three filters (the `q` skills clause, `skill`, `has_capacity`) are dropped without `admin:members:read` so search cannot become an oracle for the hidden field — and the response carries `hr_visible` so the UI says "restricted" rather than leaving a blank strip to read as "nobody filled it in". Load is **computed from open assigned tasks** and carries `unestimated`, because a bar built from the estimate sum alone shows somebody holding thirty un-estimated tasks as completely free. The work panel is scoped by the **viewer's** grants and answers `available:false` without `feature:projects`. **Registration is FIVE places, not four** — the fifth is `test_org_access_enforcement.GATED_ROUTERS`, hand-maintained, where an absent router is unchecked rather than passing; also added the named `test_projects_is_registered_on_both_sides` that WS-27a never wrote. **b-write BUILT 2026-08-07** (`people/components/PersonEditor.tsx`, `people/lib/form.ts`, `people/lib/write.ts`; 26 hermetic + 23 vitest cases, 7 mutants red): closes the regression the 2026-08-06 scope narrowing opened — deleting the tasks app's People view took `PersonEditor` with it, and with it the only UI for creating a person, editing skills and uploading a résumé. The GET-only `/api/people` proxy is **unchanged**: the writes go to `/api/tasks/people`, where they have always lived. Controls are absent rather than disabled, driven by a new **`can_manage`** flag on the reads — hide-rather-than-disable is impossible unless the read tells the UI, and the alternative is drawing the button and letting the click find a 403. **Restoring it turned up three ways migration 148 had already broken the write routes**, each a 500 in front of an admin rather than a test failure: the status vocabulary moved (49's `inactive`/`on_leave` vs 148's CHECK) and is now ONE tuple shared by filter, facets, select and validation; `create_person` still refused a duplicate NAME, preserving precisely the behaviour 148 dropped `UNIQUE(name)` to remove; and nothing checked the address 148 made unique, so a duplicate was an `IntegrityError` instead of a 409 naming the other row. **The lesson recorded in spec §10:** a migration that changes a table's shape has to be walked against every route that WRITES it — the read routes were built after 148 and were correct by construction, the write routes predated it and were never revisited | +| 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) | ---- +### Apps -## 3. Decisions recorded (2026-07-31) +| WS | Workstream | State | Owning spec · record | Gates · next (verified) | +|---|---|---|---|---| +| WS-17 | **Email completion** | 🔴 owner calls | `email_app_master_plan.md` | Three owner decisions pending (kill-list batch, schedule-send go, contact-merge identity) + user-parked semantic search. §7 Tier-1 hardening is 🟢 AGENT-SAFE and gates the second account — ⚠️ a second mailbox connected 2026-08-05; re-verify §7's single-account premise at dispatch. | +| WS-18 | **Tasks Phase 3** (Weekly Review, Waiting-For, ~~Horizons~~) | 🟡 partial | `task_manager_app.md` · board record 2026-08-09 | Waiting-For surfacing BUILT 2026-08-02, pending review (explicit-promise semantics settled). 🔴 Weekly Review NO-GO until the `gtd_reviews.summary` JSON contract + per-movement done-whens are written. Horizons: **WS-21 owns it** (§4) — DO-NOT-DISPATCH stands. 🔴 nudge **sending** (shared gate, §6) · ClickUp write-back waits on BO-1. EVAL-LOCKED: `propose()`/`propose_with_llm()`. ⚠️ WS-27e one-store: coordinate any `gtd_*` schema work with WS-27h's retirement plan. (2026-08-02) | +| WS-19 | **Notes + meeting bot** | 🟡 | `note_taker_app.md` + `meeting_bot_platform_plan.md` | Bot Phase 2 error codes 🟢 AGENT-SAFE. 🔴 bot Google account (§6) · share-to-chat needs a Slack integration that does not exist (scope call). ⚠️ D15 flag (2026-08-09): the bot plan's **ELv2 compliance argument reads "not a SaaS we resell"** (`meeting_bot_platform_plan.md`, Attendee is ELv2 not OSS) — re-evaluate before any external tenant uses bot features. | +| WS-20 | **WhatsApp activation + remainder** | 🟡 owner | `whatsapp_message_manager.md` §11 | Search UI 🟢 AGENT-SAFE; OCR needs a vision-tier decision; Odoo/Zoho-bound items bind to `crm` `entity_ref` per WS-26d instead — the linker (nothing writes `wa_contacts.entity_ref`) is owed by whoever takes them. 🔴 Meta env/app review · `WHATSAPP_ENRICHMENT` flip (§6). (2026-08-01) | +| 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-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) | -Resolutions for the cross-doc conflicts the audit surfaced. D1–D8, **D13** and -**D14** are **proposed defaults, adopted unless the owner objects** (D13 and D14 -are labelled `agent-proposed, owner may overrule` in their owning specs and stay -distinct from D11/D12's `owner-answered`); D9, D10, D11 and D12 are owner calls, -taken and dated. +--- +## 3. Decisions recorded (D1–D14: 2026-07-31→08-04 · D15/D16: 2026-08-08 · D17/D18: 2026-08-09) + +Resolutions for the cross-doc conflicts the audit surfaced. D1–D8, **D13**, **D14**, +**D16** and **D17** are **proposed defaults, adopted unless the owner objects** +(`agent-proposed, owner may overrule`); D9, D10, D11, D12, D15 and **D18** are owner +calls, taken and dated. ⚠️ Two entries below are superseded and kept as records: +**D11** (re-taken by D15) and **D10 part 1's planning premise** (re-scoped by +D15/D16) — read their banners before citing either. + +- **D18 — Three owner calls taken 2026-08-09** *(via the consolidation session's + question round; recorded here so none is re-litigated).* + 1. **Priority of record: parallel + ratchet.** App workstreams continue at full + speed alongside WS-29; the price is **R5** (§1) — tenant-ready by + construction, enforced by the shipped ratchet tests, so H2's 561-site + conversion surface stops growing in unconvertible ways. Neither an MT-first + freeze nor unruled parallelism was chosen. + 2. **Board format: compact rows.** §2 rows carry state + gates + pointers only; + narrative lives in each owning spec's "Board record (2026-08-09)" section. + Rationale: rows had reached 29.5k characters and §2 ~77k tokens — unreadable + in one pass by the dispatch loop's supervisor, whose own contract says to + read §2 only. + 3. **MT-2/MT-3 business inputs answered** (were the blockers in + `saas_multitenancy.md` §8 items 1–2): **modules sell as Core ₹600/user/month** + (Tasks, Calendar, Chat, People directory) **+ ₹300/user/month per add-on + module** (CRM, Projects, Email, Meetings, WhatsApp, Workflows); **AI resells + as a ₹10 "AI action" credit unit at ~50% gross margin** (rate card prices + each model call at provider cost × 2, denominated in credits; credits sold + via the rate card, never provider tokens). Recorded in `saas_multitenancy.md` + §8; MT-4's payment-provider split (§8 item 3) remains the one open input. +- **D17 — Mem0 binds the tenant via connection options (Option A).** + *(`agent-proposed, owner may overrule` — 2026-08-09; owning spec + `saas_multitenancy.md` §0.1 path 8, shapes in `_implementation.md` §2.4.)* + MT-1c's done-when 4 required this decision taken and written down — "leaving it + undecided fails the ticket". The call: Mem0's pgvector conninfo gains + `options=-c app.tenant_id=` so the same RLS policies govern memory rows as + every other table; per-tenant roles (B) add operational surface for no isolation + gain, and scope-string-only (C) is an accepted-risk fallback nobody has accepted. + Consequence: `org:global` memory scope becomes tenant-global, not + deployment-global — coordinate with WS-10 S1 before adding any scope shape + (`saas_multitenancy.md` §1.9). + +- **D16 — The agent sandbox splits; the raw-SQL tool goes now, the container + tier waits for the pooled cutover.** *(`agent-proposed, owner may overrule` — + 2026-08-08, owner delegated the call.)* MT-0c bundled four clauses of wildly + different cost and urgency, so the cheapest and most valuable waited behind the + most expensive. **MT-0c-1 (built):** `query_history` took a *model-generated SQL + string* and ran it through `acb_graph` — and its keyword guard was wrong both + ways, rejecting its own documented example (`CREATED_AT` contains `CREATE`) + while letting `SELECT * FROM provider_keys` straight through. That is a live + within-org read primitive **today**, so it is fixed now: search criteria, bound + parameters, two tables, plus a build-failing ratchet against the shape + returning. **MT-0c-2 (still parked, still OWNER-GATE):** the container/microVM + tier. **D10's reasoning survives for the silo phase** — one tenant per box means + an escaped agent reaches only the data it already had — so T2 becomes a + precondition of the **§5.1 pooled cutover** (customer 8–12), not of Phase 0. + Building Firecracker-grade isolation before customer #1 is speculative + infrastructure paid for out of the runway that should be buying customers. + Owner: **WS-29**, spec `specs/saas_multitenancy.md` MT-0c. +- **D15 — The tenant boundary is a ROW, not a deployment.** *(owner-requested + 2026-08-08; re-takes **D11**.)* Tenant = `organization_id`, enforced by Postgres + **FORCE ROW LEVEL SECURITY** bound at the `get_db()` seam with `SET LOCAL + app.tenant_id`; the deployment becomes a *placement* (region/tier), and a dedicated + database or stack survives as a **priced tier**, not the architecture. D11's cost + objection — *"a `WHERE organization_id = ?` on 111 tables and every query"* — does not + hold: connection sites are a bounded set of **eight** (`saas_multitenancy.md` §0.1) and + **zero existing `SELECT`/`INSERT` statements are rewritten**. **D11's §2–§5 survive + untouched** — this changes tenancy only, never visibility. Consequences: row-level + tenancy, an org switcher, multi-org users and per-org credentials all move **into** + scope (D11 §6 listed all four as out); leak sites 1–10 stop being moot and become + MT-1i; and **MT-0c requires un-parking D10's T2**, because "trusted colleagues, not + hostile users" is exactly the threat model that selling externally retires. Owner: + **WS-29**, spec `specs/saas_multitenancy.md`. - **D1 — Cost attribution is one workstream.** Stamp every LLM call at the gateway choke points with (run_id, member_email, agent, instance). Per-room (multiplayer §5.3), per-instance (agent-kinds §9.4), per-member and @@ -211,10 +310,23 @@ taken and dated. phrasing that preserves each sentence's meaning, including the two security-requirement sites (`agent_platform_hardening` §64's T2 gate now reads "Before multi-tenant (a second org on this platform)"). The name no - longer appears anywhere outside this decision record. + longer appears anywhere outside this decision record. *(2026-08-09: the + replacement phrase itself — "a second tenant deployment" — embodied D11 and + was re-swept to organization/placement language after D15; + `department_centers.md` §4 Q1 keeps the twelve-site inventory as history.)* - **D10 — Two owner calls taken 2026-08-03.** Recorded here so neither is re-litigated by a later dispatch. - 1. **Command Center is an internal Fracktal tool.** The team uses it; there + 1. **Command Center is an internal Fracktal tool.** *(⚠️ Premise re-scoped + 2026-08-08 by D15/D16: still true as a fact today — no external tenant + exists yet — but no longer the planning posture; WS-29 exists to retire it. + The T2 parking below survives in narrowed form: un-parking is a + precondition of the §5.1 pooled cutover (D16), not "a second org on this + platform, or agent authorship from outside Fracktal". Every doc decision + that rests on this premise was annotated with its expiry trigger in the + 2026-08-09 sweep — `agent_platform_hardening_2026-07.md` §1.5, + `permissions_sandbox_b6.md` §P5-c/d, `workflows_app.md` §1.4, + `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-1's enforce posture, + `meeting_bot_platform_plan.md`'s ELv2 argument.)* The team uses it; there are no external tenants and no third-party agent authors. **Consequence, already applied in the specs:** WS-3's T2 / full run sandboxing (`permissions_sandbox_b6.md` §P5-c) is **parked** under a @@ -235,7 +347,12 @@ taken and dated. the Integration Registry has the integration), not the control-flow *vocabulary*** — and must not be cited as a blocker on WS-11's 8.3c. Recorded in `workflows_app.md` §8.3c and §11 R1. -- **D11 — The tenant boundary is THE DEPLOYMENT.** *(owner call, 2026-08-03.)* +- **D11 — ⛔ SUPERSEDED: the tenant boundary is THE DEPLOYMENT.** *(owner call, + 2026-08-03 — **re-taken 2026-08-08 by D15**. Retained verbatim below as the + decision record; do not build against it and do not cite it for tenancy — cite + D15. Its §2–§5 visibility content was never touched; its TV-1 carve-out is + absorbed as WS-29 MT-1i, where the three joins are re-classed from + "wrong within one org" to "leak across tenants".)* One deployment per tenant: a second organization gets its own box, its own database, its own credential set. **Row-level organization isolation is explicitly NOT being built.** Consequences, each verified against code and @@ -319,6 +436,11 @@ taken and dated. `packages/acb_auth/acb_auth/permissions.py` (pure, already owns the permission vocabulary, no new import edge), because "the shared validator" previously named no module and no shared home existed. Acceptance: `department_centers.md` C1. + *(⚠️ 2026-08-09: re-audit C1 against WS-27e's owner-directed one-store revision + (D-PM-6 — `pm_tasks` is THE task table, WS-27h retires `gtd_items`) before + dispatching — the `gtd_*`-local grant table may be building on a floor that is + scheduled for demolition. The subject grammar rule above is unconditional either + way.)* - **D14 — `manager`'s "org-wide visibility" is not `data:org:read`, and `data:org:read` should not be relied on by anything.** *(`agent-proposed, @@ -354,7 +476,11 @@ taken and dated. organisation, it is exactly the shape of D12, and no acceptance should be written for it until the owner decides. **Consequence if the owner does nothing:** `manager` stays as seeded and WS-24's matrix labels it accurately; - nothing breaks, and the only standing rule is (i). + nothing breaks, and the only standing rule is (i). *(2026-08-09: the + zero-consumer measurement is retired — WS-27d's full-portfolio view is + deliberately `data:org:read`'s first consumer, and granting it to a real + member is owner-gated in §6 WS-27 (d). Part (i) still binds for every other + spec: name the consumer or don't cite the permission.)* ## 4. Single-owner registry (who owns duplicated work) @@ -382,16 +508,21 @@ taken and dated. | Native CRM + the Zoho retirement path | **WS-26 — `specs/crm_app.md`** (minted 2026-08-05) | `department_centers.md` Sales Center "Pipeline" app (a projection of `/crm`, flipped live by WS-26c) · WS-1 interplay **settled 2026-08-05 (D-CRM-7/D-CRM-8) — the writer now EXISTS** (branch `ws-26b-zoho-sync`): `ingestion/sources/zoho/writer.py`, the sync engine's **single, broker-gated** writer with one grep-asserted caller (`routes/crm/sync_zoho.py::execute_push`) and three registered `crm.zoho_*` handlers that auto-apply while `ACTION_BROKER_ENFORCE` is off, retired at WS-26e. WS-1's "no Zoho write path anywhere in the repo" sentence was corrected in that same change (done-when 6) — this row and the WS-1 row now agree, and neither should be re-softened · WS-2 (the Zoho-token P0's endgame is WS-26e's **revoke**) · WS-20 §11's "Odoo/Zoho-bound items" (bind to `crm` `entity_ref` per WS-26d instead — ⚠️ as of 2026-08-06 WS-26d has made `"crm"` a KNOWN system so such a ref **parses**, but there is still no linker: nothing writes `wa_contacts.entity_ref` for any system, and the drawer's `crm` block is still `None`. Whoever binds these items owes both halves) · `orchestrator/sales_views.py` + `scripts/reconciler.py` + `skills/sales\|reconciler/*` keep reading the graph mirror until WS-26e repoints them | | Native project management + the ClickUp retirement path | **WS-27 — `specs/project_management_app.md`** (minted 2026-08-05) | `task_manager_app.md` (the personal GTD lens — untouched as an app; its ClickUp provider **arm** retires at WS-27g while the provider *interface* stays, becoming the seam WS-27e's internal `commandcenter` provider uses) · `department_centers.md` C1/WS-13 (the tasks team slice and the People Center sub-app list; C1's `gtd_project_grant` = D13 stays C1's own — `pm_project_grants` is a sibling on the same subject vocabulary, never a replacement) · `task_manager_hr_planning_and_memory.md` (people/capability layer — WS-27 reads it, never rebuilds it) · `workflows_app.md` owns the automation engine WS-27f feeds (D6; the Paca-grade uplifts are recorded there as backlog, not here — **written up in full 2026-08-06 as `workflows_app.md` §13, items U1–U8**, where **U1** = the `pm.update_task` node and **U7** = agent dispatch, i.e. WS-27f's two halves, and U2–U6/U8 are engine work WS-27 does not wait on; §13 is backlog and changes neither Slice 3 nor Slice 4) · `paca_pm_research_2026-08.md` (reference-only, owns no work) · WS-1's BO-1a/BO-1b are **named prerequisites** of WS-27c, not discoveries | | The People Center's surfaces (directory, org chart, capability search, seats) | **WS-28 — `specs/people_center_app.md`** (minted 2026-08-06) | It owns **surfaces, not facts**: `task_manager_hr_planning_and_memory.md` owns the HR data and the capability vectors · `org_access_control.md` owns identity, roles and overrides · `colleague_onboarding.md` owns the invite process and the role × app matrix · `department_centers.md` owns Centers and groups · `project_management_app.md` owns the work. WS-13's *People directory read view* is closed by WS-28b rather than staying open in Centers B | -| Tenancy boundary + visibility model (who can see what) | **`specs/tenancy_and_visibility.md`** (D11 §1 · D12 §3–§4 · the app-by-app gap table §5 · TV-1 §2) | `department_centers.md` (the "separate deployment is for a separate org, never a department" rule) · `org_access_control.md` §8 Ph2 · `multi_user_organization_research.md` §5/§7/§8/§9/§17 (**research only, and superseded for planning by the new spec**) · `groups_sessions_authority.md` §3 (the intersection rule it constrains) · D9 (the twelve "second tenant deployment" sites) | +| **Tenancy boundary** (which company) | **`specs/saas_multitenancy.md`** (**D15** §1 · the three planes §0.9 · tickets §11) + its child **`specs/saas_multitenancy_implementation.md`** (SQL, seams, ratchets, runbooks — shapes only, no decisions) | ⚠️ **`tenancy_and_visibility.md` §1 + §6 are SUPERSEDED** (D11 re-taken 2026-08-08). Cite D15 for tenancy, never D11 | +| Visibility model (who inside that company) | **`specs/tenancy_and_visibility.md`** (D12 §3–§4 · the app-by-app gap table §5 · TV-1 §2 — **unchanged and still binding**) | `department_centers.md` (the "separate deployment is for a separate org, never a department" rule) · `org_access_control.md` §8 Ph2 · `multi_user_organization_research.md` §5/§7/§8/§9/§17 (**research only, and superseded for planning by the new spec**) · `groups_sessions_authority.md` §3 (the intersection rule it constrains) · D9 (the twelve "second tenant deployment" sites) | ## 5. Documentation remediation backlog (WS-0) > **Update 2026-08-01 (doc-truth pass): EXECUTED.** All Tier 1–3 items below > were applied by a six-agent pass, each edit verified against code first. > Kept as the record of what changed. **Residual items** (new or deferred): -> 1. `ai-company-brain/AGENTS.md` build-table rows are themselves stale -> (email row says "Phase 1 open" over shipped Phases 1–3; note-taker and -> task-manager rows similarly behind) — refresh against the corrected specs. +> 1. ~~`ai-company-brain/AGENTS.md` build-table rows are themselves stale~~ +> **CLOSED 2026-08-09** — the "What Has Already Been Built (as of +> 2026-06-20)" table was retired outright rather than refreshed: it was a +> second competing status description (40%+ wrong: broker/meeting-bot/ +> WhatsApp rows claimed unbuilt over shipped work) and §4's doctrine says +> mirrors are link-only. The file now points at §2 here and the owning +> specs. > 2. `note_taker_app.md` §3.13's status-as-blockquote → proper table (cosmetic). > 3. `chat_ux.md` full archival decision (banner + supersession notes are in; > body retained as protocol reference for the still-open §12 VII–XI items). @@ -412,8 +543,32 @@ taken and dated. > `ai-company-brain/AGENTS.md:190` and `apps/AGENTS.md:23` still carry the > struck falsehood that the Action Broker *"ships with zero handlers and is > not yet wired into the write path"* — untrue since 2026-07-13; see WS-1's -> five registration sites. Both are AGENT-SAFE doc fixes, neither is in this -> change. +> five registration sites. ~~Both are AGENT-SAFE doc fixes, neither is in this +> change.~~ **CLOSED 2026-08-09** — spec index completed (16 missing rows +> added, incl. the whole calendar cluster and `crm_app.md`) and the broker +> falsehood corrected at `ai-company-brain/AGENTS.md` (glossary + build-row + +> priorities) and `apps/AGENTS.md:24`. +> 7. **2026-08-09 — WS-29 consolidation pass EXECUTED** (this change). One +> sweep, driven by four parallel audits (board digest · MT plan-of-record · +> D11/D10-language inventory · status-header inventory): **(a)** §2 compacted +> per D18, narratives → owning specs' "Board record (2026-08-09)" sections +> with corrections enumerated; **(b)** D11 and D10.1 bannered as +> superseded/re-scoped, D9's replacement phrase re-swept, D13/D14 annotated; +> **(c)** R5 minted, D17/D18 recorded; **(d)** the D15-conflict inventory +> fixed across ~25 docs (deployment-tenancy claims, internal-tool premises, +> `slug='default'` teachings, "second tenant deployment" phrasing) — rewrite +> class: `agent_platform_hardening_2026-07.md` §1.5, +> `permissions_sandbox_b6.md` P5-c/d parking, +> `docs/DESIGN_LIMITATION_native_maf_mutation.md` ("tenancy not settled" was +> false); **(e)** status headers added/corrected per the inventory (5 files +> had none; 7 contradicted fact); **(f)** WS-25 re-measured (deploys green +> 2026-08-06/07 UTC, tip health-verify failure open); **(g)** MT specs updated: +> §8 pricing inputs (D18), D17 Mem0 decision, H1 scratch-verify + PR #404, +> MT-1a anchor corrections, §5.1 cutover trigger ADOPTED. Residuals that +> remain open: §5 items 2–3 above; `multi_user_organization_research.md` +> §17.3 got its rejection banner but the doc stays research-only; +> `reference.md`/`system_architecture.md` carry stale-warning banners, not +> re-verification (re-measure before relying). **Tier 1 — status truth (hours; AGENT-SAFE; do before any dispatch):** 1. `whatsapp_message_manager.md` — header "PLANNING, no code yet" → point at @@ -472,6 +627,19 @@ drawio §12's stray Hostinger-token action item moved to WS-2's list. ## 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 +> **still parked** — narrowed by **D16** (2026-08-08). `saas_multitenancy.md` §0.9.3's +> *conditions* (no raw-SQL tool; no agent-reachable `app.tenant_id` write) are satisfied +> without it: **MT-0c-1 shipped the first**, and the second cannot be violated before +> `app.tenant_id` exists (MT-1b). What remains is the container/microVM boundary, which +> D10 parked on the "trusted colleagues" threat model — a model that survives the silo +> phase and dies at the pooled cutover. **An agent must refuse to build T2 and say so**; +> it is a precondition of the §5.1 cutover, not of Phase 0. +> +> **WS-29 — moving any customer onto the pooled tier.** Cutover is a data move against +> live customer data. AGENT-SAFE to build; **OWNER-GATE to execute.** + + > **Two identity-boundary items, measured on the running deployment 2026-08-05 — > both OWNER-GATE, and together they are what makes every other access control > in this plan trustworthy or not.** @@ -483,11 +651,14 @@ drawio §12's stray Hostinger-token action item moved to WS-2's list. > hand into `.env` alone: `deploy.yml` reconciles `.env.local` from `.env`, and > setting only the first locks out every signed-in member (see > `colleague_onboarding.md` §1.1's lockout warning). -> **⚠️ Blocked 2026-08-05:** the prescribed rotation *is* a redeploy, and the -> delivery path is broken — see `deploy_delivery_path.md`. Rotating before -> deploys work would write the new value into `.env` with no reconcile of -> `.env.local`, which is precisely the lockout the sentence above warns about. -> **Fix delivery first.** +> ~~⚠️ Blocked 2026-08-05: the prescribed rotation *is* a redeploy, and the +> delivery path is broken.~~ **UNBLOCKED 2026-08-09:** delivery recovered — +> deploys landing since 2026-08-06, six green runs on 2026-08-07 UTC (#400 +> log-verified on the box; see WS-25). The rotation is executable again via a +> redeploy; the +> both-files reconcile warning above still binds, and the tip run's +> health-verify failure (WS-25) is worth understanding before choosing the +> deploy window. > 2. ~~**Gateway `:8080` and workbench `:3001` are open to the internet**~~ > **CLOSED 2026-08-05.** Both UFW rules removed (v4 and v6); verified from > outside the box that each now refuses while `https://api.…/health` still @@ -499,7 +670,8 @@ drawio §12's stray Hostinger-token action item moved to WS-2's list. > load-bearing control, since the bypass path it guards is closed. > > Item 1 still blocks *trusting* app development: an owner predicate applied to a -> forged identity is not a control. It is now gated behind the delivery fix. +> forged identity is not a control. Delivery works again (2026-08-09), so the +> only thing between here and the rotation is the owner choosing a window. Force-push / history rewrite (BO-8) · credential rotation (Zoho, Hostinger token) · enforcement flips (`ACTION_BROKER_ENFORCE`, `AGENT_PERMISSION_MODE= diff --git a/apps/AGENTS.md b/apps/AGENTS.md index dbd587a2a..e5def7d13 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -21,7 +21,7 @@ are *loaded by* a service at runtime and are never deployed on their own. - **Sinks must be idempotent per `(source, event_type, payload)`.** Strict mode stops at the first failure, so a retry re-runs every sink that already succeeded and still never runs the ones after it. Free today (one sink); it breaks silently the day a second is registered. - email_ingestion/ -- Multi-provider email sync engine (Gmail, Microsoft 365, IMAP/SMTP, aiosmtpd inbound, background scheduler) - reconciler/ -- Nightly source-of-truth diff and escalation -- action_broker/ -- Approval-gated source-of-truth write executor: authority-tier disposition + fail-closed handler registry. **Decision core exists but ships with zero handlers and is not yet wired into the write path** — tracked as BO-1 (see `FOUNDATION_BUILDOUT_CHECKLIST.md`) +- action_broker/ -- Approval-gated source-of-truth write executor: authority-tier disposition + fail-closed handler registry. **Live and wired since 2026-07-13** *(corrected 2026-08-09 — this line falsely said "zero handlers, not wired" for weeks)*: handlers register at six sites (ClickUp, WhatsApp, workflow, app-publish, `crm.zoho_*`); `ACTION_BROKER_ENFORCE` ships OFF (audit-and-chokepoint posture), and the flip is owner-gated behind BO-1a+BO-1b — see `work_plan.md` WS-1 and `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-1 ## `agents/` — agent definitions (dynamically loaded at runtime) Identity + system prompt + tool set + integrations. Loaded via `build_agents()` diff --git a/apps/services/gateway/AGENTS.md b/apps/services/gateway/AGENTS.md index 4d2cd0577..313e0f928 100644 --- a/apps/services/gateway/AGENTS.md +++ b/apps/services/gateway/AGENTS.md @@ -163,8 +163,17 @@ safe — do not point one of them at the public hostname. ## The user-management contract — binding on every route you add here -Full contract: `ai-company-brain/specs/user_management_contract.md`. The six -that bite in *this* directory, each learned by breaking it: +Full contract: `ai-company-brain/specs/user_management_contract.md` (**eleven** +rules since 2026-08-08). The six that bite in *this* directory, each learned by +breaking it — **plus R11, which will bite here hardest when WS-29 lands:** + +> **R11 — never take the acting TENANT from input.** Not an `X-Organization-Id` +> header, not a query parameter, not a body field. It comes from the +> authenticated session or a tenant-scoped API key. Under **D15** +> (`ai-company-brain/specs/saas_multitenancy.md` §1) every route in this +> directory runs against a tenant-bound session, and the binding happens **once** +> in `acb_common.db` — so a route that reaches data any other way is the bug. +> Build shapes: `saas_multitenancy_implementation.md` §2. 1. **The app is default-deny; do not opt out to make something reachable.** `require_authenticated` is attached at the app level in `main.py`, so a new diff --git a/apps/services/gateway/gateway/rooms.py b/apps/services/gateway/gateway/rooms.py index cdf667606..a7bd85870 100644 --- a/apps/services/gateway/gateway/rooms.py +++ b/apps/services/gateway/gateway/rooms.py @@ -146,6 +146,33 @@ def _capabilities(role: str | None, *, visibility: str) -> tuple[bool, bool, boo # Resolution # --------------------------------------------------------------------------- +#: Which of a room's group subjects actually contain the caller. +#: +#: Module-level rather than inline in `_load_room` so a hermetic test can assert +#: on it without a database (`tenancy_and_visibility.md` §2 done-when 2 — the +#: DB-backed room tests skip green with no Postgres, so the tenant predicate +#: needs a string assertion that cannot skip). +#: +#: `g.organization_id = u.organization_id` is the tenant predicate. `org_group` +#: slugs are unique only *within* an organization (`UNIQUE (organization_id, +#: slug)`, `138_groups_and_session_participants.sql:49`), so joining on slug +#: alone matched the identically-named group in every other tenant — a +#: cross-organization match by construction once the tenant boundary is a row +#: rather than a deployment (`saas_multitenancy.md` §1 D15, §6.5). The org is +#: *derived* from `u`, the acting user's own row, so it cannot go stale the way +#: a literal `slug = 'default'` would (`tenancy_and_visibility.md` §2 +#: done-when 1). +MY_GROUPS_SQL = """ + SELECT g.slug + FROM org_group g + JOIN org_group_member m ON m.group_id = g.id + JOIN app_user u ON u.id = m.user_id + WHERE u.email = :email + AND g.slug = ANY(:slugs) + AND g.organization_id = u.organization_id +""" + + def _load_room(session_id: str, email: str) -> dict | None: """Read the session row and everything about this person's place in it. @@ -188,12 +215,7 @@ def _load_room(session_id: str, email: str) -> dict | None: my_groups: set[str] = set() if group_slugs: rows = s.execute( - text( - "SELECT g.slug FROM org_group g " - "JOIN org_group_member m ON m.group_id = g.id " - "JOIN app_user u ON u.id = m.user_id " - "WHERE u.email = :email AND g.slug = ANY(:slugs)" - ), + text(MY_GROUPS_SQL), {"email": email, "slugs": group_slugs}, ).fetchall() my_groups = {r.slug for r in rows} @@ -365,6 +387,13 @@ def _undecidable() -> RoomAccess: #: `org`), or the room is org-visible and they are an active member. Written as #: one EXISTS-per-way rather than a join so a session is never returned twice #: and the planner can use each subject index independently. +#: +#: The group arm carries the same tenant predicate as MY_GROUPS_SQL — +#: `g.organization_id = u.organization_id`, derived from the caller's own +#: `app_user` row. Without it the slug join admits a room shared with another +#: tenant's identically-slugged group (`saas_multitenancy.md` §6.5). Kept as a +#: predicate rather than an SQL `--` comment because callers concatenate this +#: constant into larger statements (`routes/chat.py:98,230,296,735`). SESSION_VISIBLE_SQL = """ ( s.user_id = :uid @@ -380,6 +409,7 @@ def _undecidable() -> RoomAccess: WHERE p.session_id = s.id AND p.subject LIKE 'group:%' AND u.email = :uid + AND g.organization_id = u.organization_id ) OR ( EXISTS ( diff --git a/apps/services/orchestrator/orchestrator/executor.py b/apps/services/orchestrator/orchestrator/executor.py index ad52e9716..1683c888b 100644 --- a/apps/services/orchestrator/orchestrator/executor.py +++ b/apps/services/orchestrator/orchestrator/executor.py @@ -564,7 +564,7 @@ async def _emit_sub_event(evt: dict[str, Any]) -> None: await _push_sse_to_stream(_relay_tid, _line) # type: ignore[arg-type] # B6 Phase-5 Tier 0: init before the try so the finally can always restore. - _integration_env_token: IntegrationEnvToken = {} + _integration_env_token: IntegrationEnvToken = None # Delegation is a run too, and until now it was invisible to everyone # outside the tab that started it: SUB_AGENT_* events reach the parent's @@ -596,7 +596,7 @@ async def _emit_sub_event(evt: dict[str, Any]) -> None: optional = loaded.config.get("optional_integrations", []) integrations, _ = build_integrations(mandatory, optional, settings) # Scope this sub-agent's creds to its run; restored in the finally. - _integration_env_token = _inject_integrations_to_env(integrations) + _integration_env_token = _bind_run_credentials(integrations) agents = loaded.build_agents() # Honour .github/agents/.agent.md instructions for sub-agents # too, so a delegated Copilot SDK agent keeps its authored identity. @@ -840,7 +840,7 @@ async def _emit_sub_event(evt: dict[str, Any]) -> None: pass # B6 Phase-5 Tier 0: tear down this sub-agent's scoped integration creds # so a delegated agent's secrets don't linger for the parent/next run. - _restore_integration_env(_integration_env_token) + _release_run_credentials(_integration_env_token) # Restore orchestrator's artifact context so subsequent tool calls # (including write_artifact) target the correct workspace. if _saved_artifact_ctx: @@ -2312,7 +2312,7 @@ def _respond_input_apply(command: dict[str, Any]) -> bool: # B6 Phase-5 Tier 0: initialised here so the finally can always restore, # even if load_agent / build_integrations raises before creds are injected. - _integration_env_token: IntegrationEnvToken = {} + _integration_env_token: IntegrationEnvToken = None try: with load_agent( @@ -2332,7 +2332,7 @@ def _respond_input_apply(command: dict[str, Any]) -> bool: ) # B6 Phase-5 Tier 0: scope creds to this run; token restored in the # finally below so they don't linger in the shared process env. - _integration_env_token = _inject_integrations_to_env(integrations) + _integration_env_token = _bind_run_credentials(integrations) agents = loaded.build_agents() # Honour .github/agents/.agent.md (Copilot SDK definition): # override instructions + capture model, BEFORE tool injection so @@ -4050,7 +4050,7 @@ async def _run_task() -> str: _active_run_model.reset(_model_token) # B6 Phase-5 Tier 0: tear down this run's scoped integration creds so # they don't linger in the shared process env for the next agent. - _restore_integration_env(_integration_env_token) + _release_run_credentials(_integration_env_token) try: from acb_common import clear_run_context clear_run_context() @@ -4327,90 +4327,57 @@ async def _llm_recovery( # Internal: run a MAF agent list (replaces LangGraph _execute_graph) # --------------------------------------------------------------------------- -# A restore token maps each env var this run set to its PRIOR value -# (``None`` = the var did not exist before, so restore == delete). Passed to -# ``_restore_integration_env`` at the run's teardown site. See B6 Phase-5 -# Tier 0 (permissions_sandbox_b6.md): credentials are now scoped to the run -# that needs them and torn down when it ends, instead of being written once -# into the shared gateway ``os.environ`` and accumulating there forever (where -# any later/idle agent could read another integration's secret). -IntegrationEnvToken = dict[str, "str | None"] +# Opaque handle returned by ``_bind_run_credentials`` and passed back to +# ``_release_run_credentials`` at the run's teardown site. It is a ContextVar +# ``Token``, not an env restore map — see MT-0a below. +IntegrationEnvToken = Any -def _inject_integrations_to_env( - integrations: dict[str, Any], -) -> IntegrationEnvToken: - """Export this run's resolved integration credentials into os.environ. - - Skill scripts call os.getenv("ZOHO_CLIENT_ID") etc. directly. The executor - resolves credentials into a structured dict but never writes them to the - process environment — so subprocesses spawned by agent tool functions can't - find them. This function closes that gap by mapping the structured dict - fields back to the canonical env var names. - - B6 Phase-5 Tier 0 — SCOPED, not permanent. Returns a restore token (the - prior value of every var this call SET, ``None`` if it was previously - unset); the caller passes it to :func:`_restore_integration_env` at the - run's teardown so the credentials do NOT linger in the shared process env - after the run. Previously this wrote each var once and never cleared it, so - every secret ever used accumulated in ``os.environ`` for the process - lifetime — any agent (incl. a prompt-injected one) could read any other - integration's secret regardless of its own ``config.json`` scope. - - Gateway ``.env`` still wins: a var already present in ``os.environ`` is left - untouched AND excluded from the restore token (we neither overwrite nor - later delete an operator-provided value). - - NOTE (honest limit): ``os.environ`` is process-global, so under *concurrent* - in-process runs the scoping is best-effort — two overlapping runs still - share the env for the overlap window. A real per-run env (its own boundary) - is Tier 2 (container/subprocess). Tier 0 removes the *permanent - accumulation* and scopes to the run's own declared integrations. +def _bind_run_credentials(integrations: dict[str, Any]) -> Any: + """Bind this run's resolved integration credentials to its async context. + + Skill scripts read credentials by canonical env-var name. The executor + resolves them into a structured dict, so something has to bridge the two. + Until MT-0a that bridge was the gateway's process-global ``os.environ``, + written at run start and restored at teardown. + + **MT-0a (`saas_multitenancy.md` §6.1) replaces the bridge with a ContextVar.** + The env approach removed *permanent accumulation* but could not remove + *concurrent* exposure, and the old docstring here said so outright: under + overlapping in-process runs the scoping was "best-effort — two overlapping + runs still share the env for the overlap window". Under one tenant that is a + within-org concern. Under two it is a **credential leak**: tenant A's token + is readable by tenant B's concurrently-running agent, and agents execute + model-generated tool calls over content ingested from email and WhatsApp. + + A ContextVar is per-task and is copied into tasks created from the binding + context, so the overlap window does not exist. Consumers: + + * subprocess scripts — ``code_tools._script_env`` reads the bound values; + * in-process skills — call ``acb_skills.integrations.credential(name)``, + never ``os.getenv`` (see that function's docstring for precedence). + + Returns a token the caller **must** pass to + :func:`_release_run_credentials` at teardown. Nothing is written to + ``os.environ``; the operator's own environment is left exactly as it was. """ - import os + from acb_skills.integrations import bind_run_credentials - # Canonical mapping now lives in acb_skills.integrations.FIELD_TO_ENV — - # shared with code_tools._script_env so a declared integration's scripts - # see exactly the vars this function exports (agent_coding_skill.md §9). - from acb_skills.integrations import FIELD_TO_ENV + return bind_run_credentials(integrations) - token: IntegrationEnvToken = {} - for service, creds in integrations.items(): - if not isinstance(creds, dict): - continue - for field, env_var in FIELD_TO_ENV.get(service, []): - val = creds.get(field, "") - # Gateway .env wins: never overwrite an already-present var, and - # don't record it in the token (so teardown won't delete an - # operator-provided value we didn't set). - if val and env_var not in os.environ and env_var not in token: - token[env_var] = None # was unset before this run - os.environ[env_var] = str(val) - return token - - -def _restore_integration_env(token: IntegrationEnvToken | None) -> None: - """Undo :func:`_inject_integrations_to_env` — restore each var this run set - to its prior value (``None`` prior → delete the var). - - Called at the run teardown site (batch AsyncExitStack callback, streaming - ``finally``, sub-agent ``finally``) so this run's credentials do not linger - in the shared process env for the next/concurrent-idle agent to read. - Best-effort and never raises — a teardown failure must not mask the run's - own outcome. + +def _release_run_credentials(token: Any) -> None: + """Undo :func:`_bind_run_credentials`. Never raises. + + Called at every run teardown site (batch ``AsyncExitStack`` callback, + streaming ``finally``, sub-agent ``finally``). A teardown failure must not + mask the run's own outcome, and it must not leave credentials readable — + ``release_run_credentials`` falls back to an explicit empty bind if the + token cannot be reset from the calling context. """ - if not token: - return - import os + from acb_skills.integrations import release_run_credentials - for env_var, prior in token.items(): - try: - if prior is None: - os.environ.pop(env_var, None) - else: - os.environ[env_var] = prior - except Exception: - pass + release_run_credentials(token) async def _run_with_maf_agent( @@ -4513,10 +4480,10 @@ async def _run_with_maf_agent( # and the env-var-based credential reading in skill scripts. B6 Phase-5 # Tier 0: scoped to this run — the restore token is torn down on the # AsyncExitStack below (fires even on exception) so creds don't linger. - _integration_env_token = _inject_integrations_to_env(integrations) + _integration_env_token = _bind_run_credentials(integrations) async with contextlib.AsyncExitStack() as stack: - stack.callback(_restore_integration_env, _integration_env_token) + stack.callback(_release_run_credentials, _integration_env_token) # GitHubCopilotAgent (and any agent with lifecycle) requires start/stop. # Standard Agent has a no-op __aenter__/__aexit__ — both are safe here. if hasattr(type(agent), "__aenter__"): diff --git a/apps/services/orchestrator/orchestrator/mutation.py b/apps/services/orchestrator/orchestrator/mutation.py index a3b4d4d91..6944d8c01 100644 --- a/apps/services/orchestrator/orchestrator/mutation.py +++ b/apps/services/orchestrator/orchestrator/mutation.py @@ -56,6 +56,12 @@ _MUTATION_ATTEMPTS_MAX_KEYS = 10_000 # crude unbounded-growth guard (rare path) +def _mutation_limit_reached(run_id: str, explicit_prior: int = 0) -> bool: + """Pure peek at the per-run tally — no increment, no side effects.""" + prior = max(int(explicit_prior or 0), _MUTATION_ATTEMPTS.get(run_id, 0)) + return prior >= MAX_MUTATION_ATTEMPTS + + def _register_mutation_attempt(run_id: str, explicit_prior: int = 0) -> tuple[bool, int]: """Enforce MAX_MUTATION_ATTEMPTS for *run_id*. Pure except for the counter. @@ -168,6 +174,99 @@ async def _auto_push_commit(agent_dir: str, commit_sha: str) -> bool: return False +# --------------------------------------------------------------------------- +# MT-0b — self-mutation is first-party-only +# --------------------------------------------------------------------------- +# Root ``AGENTS.md`` non-negotiable 3, verbatim: native MAF agents "land approved +# self-mutations by opening a PR against THIS Command Center monorepo … It MUST +# be swapped for a tenant-isolated mechanism before any multi-tenant/customer +# deployment — third parties must never push to the shared monorepo." +# +# So: a customer's agent failing in production must not be able to open a pull +# request against Fracktal's repository. The containment is a flag that is FALSE +# by default (``organization.first_party``, migration 157), which means a tenant +# created tomorrow is contained *by construction* rather than by someone +# remembering to contain it. +# +# This is MT-0b in ``saas_multitenancy.md`` §6.2 — the cheapest sufficient fix, +# and enough to unblock everything else. Per-tenant agent repositories and a +# mutation sandbox whose output is a tenant-scoped artifact are the fuller +# answers; neither is required to stop the leak. + +#: Operator hard-off, independent of the per-org flag. Unset means "defer to +#: ``organization.first_party``" — this exists so a deployment can kill the +#: whole mechanism without touching data. +_SELF_MUTATION_DISABLED_ENV = "SELF_MUTATION_DISABLED" + +_SQL_FIRST_PARTY_BY_ID = """ +SELECT first_party FROM organization WHERE id = :org_id +""" +#: Untenanted resolution. Deliberately requires the org to be the ONLY one: on a +#: single-tenant box this is the operator's org and behaviour is unchanged, and +#: the moment a second organization exists it returns no row and the gate fails +#: closed. A "pick the default org" fallback would keep answering true forever, +#: which is the failure this ticket exists to prevent. +_SQL_SOLE_ORG_FIRST_PARTY = """ +SELECT first_party FROM organization + WHERE (SELECT count(*) FROM organization) = 1 +""" + + +async def _self_mutation_permitted( + organization_id: str | None = None, +) -> tuple[bool, str]: + """May this organization's agents land a self-mutation? Fails CLOSED. + + Returns ``(allowed, reason)``; *reason* is empty when allowed and is written + into :attr:`MutationResult.skipped_reason` otherwise, so an operator reading + the approval inbox can tell containment from a crash. + + **Every failure path returns False.** An unreachable database, a missing + column, a second organization on a box that resolved untenanted — all of + them mean "we cannot prove this is first-party", and the only safe answer to + that is no. Availability of self-mutation is worth far less than the + guarantee that a customer's agent never pushes to our monorepo. + """ + if os.environ.get(_SELF_MUTATION_DISABLED_ENV, "").strip().lower() in ( + "1", "true", "yes", "on", + ): + return False, "self-mutation is disabled on this deployment (SELF_MUTATION_DISABLED)" + + try: + from acb_common.db import get_db + from sqlalchemy import text + + session = await get_db() + try: + if organization_id: + row = (await session.execute( + text(_SQL_FIRST_PARTY_BY_ID), {"org_id": organization_id}, + )).first() + else: + row = (await session.execute(text(_SQL_SOLE_ORG_FIRST_PARTY))).first() + finally: + await session.close() + except Exception as exc: + _log.warning("mutation.first_party_check_failed", error=str(exc)[:200]) + return False, ( + "could not establish that this organization is first-party " + "(the check failed, so self-mutation is refused)" + ) + + if row is None: + return False, ( + "no single first-party organization resolved — self-mutation is " + "refused for tenants (saas_multitenancy.md §6.2 / MT-0b)" + ) + if not bool(row[0]): + return False, ( + "this organization is not flagged first-party; a tenant's agent may " + "not open a pull request against the CommandCenter monorepo " + "(root AGENTS.md non-negotiable 3)" + ) + return True, "" + + # --------------------------------------------------------------------------- # Public entry point # --------------------------------------------------------------------------- @@ -180,6 +279,7 @@ async def attempt_self_mutation( mutation_attempts: int = 0, agent_dir: str | None = None, incompatibility: bool = False, + organization_id: str | None = None, ) -> MutationResult: """Attempt to fix a failing agent using an isolated Copilot SDK sandbox. @@ -204,11 +304,56 @@ async def attempt_self_mutation( referencing ``agent_repo_compatibility.md`` and is asked to generate a compliant ``agents.py``. + organization_id: The org this run belongs to. ``None`` resolves to the + sole organization, which exists only on a + single-tenant box — see + :func:`_self_mutation_permitted`. + Returns: A :class:`MutationResult` describing what happened. """ + # An at-the-limit run is refused by a pure in-memory peek BEFORE anything + # else — no database round-trip, no attempt consumed, and the refusal + # reason names the limit rather than whatever the first-party gate would + # have said. + if _mutation_limit_reached(run_id, mutation_attempts): + reason = ( + f"max_mutation_attempts={MAX_MUTATION_ATTEMPTS} already reached. " + "A human must merge the pending PR before the live system can retry." + ) + _log.info( + "mutation.skipped", + agent=agent_name, + run_id=run_id, + reason=reason, + ) + return MutationResult( + agent_name=agent_name, + run_id=run_id, + attempted=False, + skipped_reason=reason, + ) + + # MT-0b — before the tally is consumed, the sandbox, git, or anything that + # could reach the network. A tenant's failure must cost nothing, including + # the run's one attempt when this gate itself fails transiently. + _permitted, _deny_reason = await _self_mutation_permitted(organization_id) + if not _permitted: + _log.info( + "mutation.refused_not_first_party", + agent=agent_name, run_id=run_id, reason=_deny_reason, + ) + return MutationResult( + agent_name=agent_name, + run_id=run_id, + attempted=False, + skipped_reason=_deny_reason, + ) + _allowed, _attempt_no = _register_mutation_attempt(run_id, mutation_attempts) if not _allowed: + # Lost a race with a concurrent re-entry for the same run between the + # peek above and this registration. reason = ( f"max_mutation_attempts={MAX_MUTATION_ATTEMPTS} already reached. " "A human must merge the pending PR before the live system can retry." diff --git a/apps/skills/skill-clickup-sync/skill_clickup_sync/core.py b/apps/skills/skill-clickup-sync/skill_clickup_sync/core.py index 5e2e1db0c..695384c3c 100644 --- a/apps/skills/skill-clickup-sync/skill_clickup_sync/core.py +++ b/apps/skills/skill-clickup-sync/skill_clickup_sync/core.py @@ -8,8 +8,7 @@ """ from __future__ import annotations -import os -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any import httpx @@ -26,7 +25,13 @@ def _wrap(fn): def _api_token() -> str: - tok = os.environ.get("CLICKUP_API_TOKEN", "") + # MT-0a: read the RUN's credential, not the process environment. This skill + # runs IN-PROCESS as a MAF tool, so `os.environ` here would return whatever + # a concurrent run had exported — the leak `saas_multitenancy.md` §6.1 + # describes. `credential()` still lets an operator-provided value win. + from acb_skills.integrations import credential + + tok = credential("CLICKUP_API_TOKEN") if not tok: raise RuntimeError("CLICKUP_API_TOKEN is not set") return tok @@ -58,7 +63,7 @@ async def get_task_status(task_id: str) -> str: due = "" if due_raw: try: - dt = datetime.fromtimestamp(int(due_raw) / 1000, tz=timezone.utc) + dt = datetime.fromtimestamp(int(due_raw) / 1000, tz=UTC) due = f" · due {dt.strftime('%Y-%m-%d')}" except (TypeError, ValueError): pass @@ -86,7 +91,9 @@ async def list_project_tasks(project_name: str, *, status_filter: str = "") -> s Returns: A plain-text task list for the agent context window. """ - workspace_id = os.environ.get("CLICKUP_WORKSPACE_ID", "") + from acb_skills.integrations import credential # MT-0a — see _api_token + + workspace_id = credential("CLICKUP_WORKSPACE_ID") if not workspace_id: return "CLICKUP_WORKSPACE_ID is not configured — cannot list tasks." @@ -148,4 +155,4 @@ async def list_project_tasks(project_name: str, *, status_filter: str = "") -> s assignee = (t.get("assignees") or [{}])[0].get("username", "unassigned") lines.append(f" [{status}] {t.get('name', '?')} — {assignee}") - return "\n".join(lines) \ No newline at end of file + return "\n".join(lines) diff --git a/docs/DESIGN_LIMITATION_native_maf_mutation.md b/docs/DESIGN_LIMITATION_native_maf_mutation.md index d6ef5f103..8ba982b39 100644 --- a/docs/DESIGN_LIMITATION_native_maf_mutation.md +++ b/docs/DESIGN_LIMITATION_native_maf_mutation.md @@ -2,7 +2,8 @@ **Status:** interim mechanism, in use while Command Center is a work in progress. **Must be replaced before:** any production / multi-tenant deployment where agents -are run on behalf of third parties or customers. +are run on behalf of third parties or customers. *(ticketed: saas_multitenancy.md +MT-0b — built 2026-08-08, pending review; root AGENTS.md constraint 3 tracks it)* **Owner decision:** flagged 2026-07-15 — "we have to figure this out later." --- @@ -56,8 +57,12 @@ evaluate when we get there (not yet decided): agents are provisioned into isolated forks with their own approval + deploy lane. -The decision hinges on the tenancy model we land on for production, which is not -settled yet — hence this note rather than an implementation. +The tenancy model **was settled on 2026-08-08** (D15: organization_id + RLS, +deployment = placement — `ai-company-brain/specs/saas_multitenancy.md` §1), and +this limitation is ticketed as **MT-0b (WS-29)**: a config gate defaulting to +disabled, **BUILT 2026-08-08 pending review** (migration 157 adds +`organization.first_party`; mutation refuses non-first-party targets). This note +stays until MT-0b is merged and verified. ## Guardrail until then @@ -65,7 +70,8 @@ settled yet — hence this note rather than an implementation. and only in first-party/dev environments. - Do **not** enable the monorepo-PR path for any agent that is not first-party. - Before shipping multi-tenant Command Center, replace this path per the design - chosen above and delete this limitation once resolved. + chosen above and delete this limitation once resolved. *(= merging MT-0b; see + above)* See also: the `mutation_monorepo_repo` / `mutation_pr_token` settings docstrings in `packages/acb_common/acb_common/settings.py`, and the header of diff --git a/docs/app-workshop/README.md b/docs/app-workshop/README.md index 199a7b0f2..36703d50d 100644 --- a/docs/app-workshop/README.md +++ b/docs/app-workshop/README.md @@ -114,7 +114,9 @@ enforces. Translated internally: `window.cc.*` + SSO identity + manifest scopes. **consent is keyed to (user, app, scope-set)** — a new version with the same scopes inherits consent; widened scopes re-prompt. Deployments = immutable versions behind a stable URL; rollback = repoint. `executeAs` picks **run-as-viewer vs run-as-author**. - Domain trust removes verification friction inside one org. + Domain trust removes verification friction inside one org. *(per-org under D15 — + each tenant brings its own domain; the friction argument holds within a tenant, + not across)* - **Val Town** — platform **injects a short-lived, down-scoped API token** into the running val; std-library wrappers (`std/sqlite`, `std/email`, `std/openai`) use it transparently; dangerous scopes are excluded by default. Pure run-as-author. diff --git a/docs/multiplayer/memory-clearance.md b/docs/multiplayer/memory-clearance.md index c6310b0ac..9998967fd 100644 --- a/docs/multiplayer/memory-clearance.md +++ b/docs/multiplayer/memory-clearance.md @@ -336,6 +336,9 @@ knowledge the framework doc calls *"prompt that grows over time."* tenant key — so today there is **one `agent-data/NOTES.md` per agent, shared by every user of it**, and `recall_notes(path)` with no query returns the whole file. A vector fact leaks only on a semantic match; the file tier is simply *there*, in full, on every run that loads it. +*[2026-08-09: under D15 this is a live multi-tenancy gap, not a latent one — the fix rides +WS-29 (MT-1b/MT-1g for keys; the `slug='default'` resolution is implementation-spec trap 5). +Do not copy this pattern into new code — R5.]* It needs the same instance key and the same write rule as the compartments, and the two must land in the same phase — partitioning the vector tier while leaving the file tier shared fixes @@ -867,6 +870,9 @@ from step 2, empty whenever step 3 fired. The cap block on `PATCH /sessions/{id} > `get_org_id` resolves a single deployment org (`routes/admin/_common.py:96-112`, keyed on > `DEFAULT_ORG_SLUG`). Provision a second organization and an identically-slugged group in it > expands into this one's clearance. Filter on `organization_id` in the copy. +> *[2026-08-09: under D15 this is a live multi-tenancy gap, not a latent one — the fix rides +> WS-29 (MT-1b/MT-1g for keys; the `slug='default'` resolution is implementation-spec trap 5). +> Do not copy this pattern into new code — R5.]* **Done when** 1. Owner binds to a compartment they belong to → 200; `chat_session.subject_ref` is set; @@ -1248,3 +1254,18 @@ git grep -n "PREFS_SCOPE_PREFIX\|prefs=True" -- packages apps # R1: the next free migration number, at build time only ls infra/postgres/*.sql | sed 's#.*/##' | sort -n | tail -1 ``` + +## 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 +> carry state + gates only. The narrative below is preserved verbatim from the +> final long-form row; the dated corrections after it win where they conflict. + +### WS-10 — **Multiplayer remainder** — S1 `subject:` compartments · floor-control re-decision · `prefs`/`user` backfill + +**State cell (as of the move):** 🟡 Docs → S1 + +**Narrative (verbatim):** **Steer is SHIPPED — struck from this row's title** (`15c8933f`, ancestor of `main`: `orchestrator/steer.py::route_turn` → DROP/ENGAGE/ABORT/STEER, durable `cc:steer:` signals, `202 {"steered": true}` stand-down, `409 steer_outside_run_floor`, plus the two-layer supersede guard; `tests/unit/test_steer_routing.py` + `test_supersede_guard.py` green). **Audited 2026-08-01 → NO-GO on 5 of 7 contract points; §5-style remediation applied 2026-08-02** (both docs re-headered "verified against code on 2026-08-02", §3.5's 5 stale anchors fixed, gate labels added, verification blocks added). **That remediation was then independently verified and returned FAIL; repair round 1 landed the same day.** The P0 was the remediation's own new claim that `mark_active(reset=True)` raises `SupersedeRefused` — **it does not**: `mark_active` (`stream_relay.py:343-405`) deletes the stream at `:377` with no ownership check, and the only `raise` is at `:895` inside **`run_detached`** (`:823`), before it calls `mark_active` at `:909`. So the guard covers `run_detached`'s callers, **not** the destructive statement; both docs now say so, and README §12.3 carries an anchor grep that shows the line ordering. Six smaller defects fixed with it: `feature:memory` is `permissions.py:68` (not `:70`); §7.1.3 dw1 said "member" where §7.1.5 allows members (now **non-member**); dw5's `409` now matches its own precedent's **400** (`routes/rooms.py:533-538`); the slug grammar no longer claims `_clean_slug` (which forbids `.`, allows a leading `-`/`_` and unicode alnum) — it is `_SEGMENT_RE`'s shape plus a 64-char bound; `subject_ref` now reads as a **compartment scope key** everywhere (§3.2/§3.4/§4.1/§7.1.8), not an entity ref; and three already-green done-whens (§7.1.1 dw2/dw3, §7.1.5's miscounted row) were **replaced with criteria that require the work**, not merely labelled. Residual recorded, not built: moving the ownership check into `mark_active` would make it an invariant over the statement — no ticket minted for it here. **The row is now three things, and only one is work:** ① **`subject:` compartments = WS-10 S1, the dispatchable slice.** It is the one item with real query-layer acceptance (`memory-clearance.md` §7, kept verbatim) — it was NO-GO only because the surface it presumes was unspecified. **`memory-clearance.md` §7.1 now specifies it** (create/add-member endpoints and their gating, the `subject_ref` writer folded into the existing `PATCH /sessions/{id}/room`, the `_authorize_scope` rule, `audience='team'` → the shipped `org_group`, and a testable `sensitivity='restricted'` = *existence is confidential*, 404-not-403). Every decision there is marked `DECISION (agent-proposed, owner may overrule)` — **AGENT-SAFE once §7.1 is accepted**: dispatch after the owner reads it, or overrule and re-dispatch. (The owning spec's own Gate cell now carries that qualifier too — it read an unqualified "AGENT-SAFE" until 2026-08-02, and by this board's Authority rule the owning spec out-ranks this row for *what to build and how*, so the weakest of the three preconditions was the one that would have won.) **Repair round 2 (2026-08-02) — adversarial review returned REQUEST-CHANGES with no P0 and five P1s; all repaired in the same change.** The one that mattered: §7.1.4 specified the clearance cap as *"computed the way `_capability_cap` (`rooms.py:191`) already computes the credential cap"* — but `_capability_cap` **drops `group:` and `org` subjects by design** (`:207`, and short-circuits empty at `:208-212`, its own comment at `:209-211` saying so), so an implementer following that pointer would have turned the intersection into a **union** for exactly the rooms where a leak is widest: `[owner@x, group:sales]` bound to a restricted subject would come back with an empty cap, read as "no non-member participants", and admit the compartment to `Clearance.read` for forty people — while done-when 4 ("a non-member participant") passed green against two email addresses. §7.1.4 now names the site (`_subject_clearance_cap` beside `_capability_cap` in `routes/rooms.py`, consumed at the tree's only `resolve_clearance` call site, `routes/agent.py:1768-1774`), requires participants to be **expanded before** the intersection through one factored-out helper (`acb_auth.access.expand_session_subjects`, lifted out of `resolve_session_access` `:343-434` which already does the `group:`/`org` expansion at `:330-340`), and requires that expansion to **fail closed** — the opposite posture to `resolve_session_access`'s deliberate fail-open at `:417-426`, stated as such so nobody "fixes" it back. Done-when 4 is now four parts that cannot be satisfied without the `group:` and `org` cases. The other four P1s: the prior-art doc's QM-1 state cell still read "designed, unbuilt" for shipped steer (and QM-2 "✖" for built-but-off S4) while two other files in the same change said built; README §2's anchor table was **7 wrong of 8** under a "verified" header (fixed + caveat added, plus four stale repeats outside the table); §5.2 cited `test_reset_wipes_the_event_log` as demonstrating the `mark_active` bypass when that test seeds no `cc:runactor:` and so **cannot distinguish the two states** (README now says no test demonstrates it and describes the one that would); and §7.1.4 done-when 6 asserted a `422` on unknown `PATCH` keys that shipped code does not produce — `RoomPatch` (`routes/rooms.py:81-84`) is a plain `BaseModel`, no `extra="forbid"` anywhere in the gateway, verified against the repo interpreter (pydantic 2.13.4) — so it now pins the *real* behaviour and closing the model is filed as its own ticket in §7.1.9 rather than smuggled into this slice. ② **Floor control = OWNER-GATE, registered in §6 by name.** Per QM-1 steer dissolved most of the problem the baton was invented for; README §8 Phase 2 says whether the five modes still earn their place is *"pending the owner's re-decision"*. No acceptance is written for it on purpose — writing one would make an owner call look like queued work. ③ **`prefs`/`user` backfill** — classifier + **dry-run report** is AGENT-SAFE; **applying it is OWNER-GATE**, registered in §6 (mutates live Mem0). Verified: nothing writes a `prefs:` key anywhere today, so `prefs:` is permanently empty until this runs. **Two prior-art corrections (2026-08-02):** QM-3's *"rather than one `acting_identity`"* was factually wrong — there is no such column and never was (mig 138 `:26` rejects it explicitly), so QM-3 is net-new work with zero acceptance and maps to **WS-2 / WS-1, not here**; and the R2 phase-ID collision is resolved — the prior-art doc called `subject:` compartments "3b" while the owning spec puts them in the **3a remainder**, so the owning spec's ID wins and the board calls the slice **S1**. QM-5 (tenure narrows the model, not just the viewer) is a **real gap with an undone design**: viewer half built (mig 138 `:97-98` → `rooms.py:277-292` → `chat.py:314-316`), model half not (`_get_messages(thread_id, _hist_uid, …)` at `routes/agent.py:1947-1956` narrows by the acting caller only) — but README §6.5 says the two mechanisms are *"worth comparing before building either"*, which is a decision to record, not acceptance. + +**Corrections applied 2026-08-09:** +- `org:global` scope must become tenant-scoped under D15 — coordinate S1 with WS-29 MT-1c and D17 (Mem0 binds tenant via connection options); do not mint a sixth scope shape (`saas_multitenancy.md` §1.9). diff --git a/docs/workflow-editor/README.md b/docs/workflow-editor/README.md index 79066429a..cd96e16f2 100644 --- a/docs/workflow-editor/README.md +++ b/docs/workflow-editor/README.md @@ -436,7 +436,10 @@ payload and enqueues a `workflow_run` (Copilot Studio's unified-trigger idea, Si 4. **Cron/scheduling.** Adopt APScheduler vs extend the hand-rolled asyncio loops. A real cron parser is worth it once schedule triggers exist. 5. **Multi-tenant scoping.** Workflows are workspace-scoped; reuse the header-trust - SSO + RBAC (`acb_auth`). Who can publish (executive vs employee)? + SSO + RBAC (`acb_auth`). *[2026-08-09: header-trust is for IDENTITY behind the + BFF only — the acting TENANT must never come from a header + (user_management_contract.md R11; saas_multitenancy.md §7 item 2). Workflows + become org-scoped via RLS at MT-1b.]* Who can publish (executive vs employee)? 6. **Secrets in nodes.** Nodes must never read raw credentials — resolve through the integrations registry at run time, exactly as agents do today. 7. **MCP.** Both references treat integrations as MCP. CommandCenter already has MCP diff --git a/evals/trajectories/test_integration_env_scoping_trajectory.py b/evals/trajectories/test_integration_env_scoping_trajectory.py index 9cfdaaba1..4129db950 100644 --- a/evals/trajectories/test_integration_env_scoping_trajectory.py +++ b/evals/trajectories/test_integration_env_scoping_trajectory.py @@ -1,22 +1,26 @@ -"""Golden trajectory: per-run integration-credential scoping (B6 Phase-5 Tier 0). - -Locks the SECURITY invariant, not just the mechanics: a credential materialised -into the shared process ``os.environ`` for run A must NOT still be readable when -run B (a different agent / different integration) starts. This is the concrete -"any agent can read any other integration's secret" hole the isolation work -closes at Tier 0. - -If a future edit reverts ``_inject_integrations_to_env`` to write-and-never-clear -(or drops the teardown at any of the three run sites), the accumulation assertion -here fails. - -See specs/permissions_sandbox_b6.md (Phase 5, Tier 0). +"""Golden trajectory: per-run integration-credential scoping (MT-0a). + +Locks the SECURITY invariant, not just the mechanics: a credential resolved for +run A must NOT be readable when run B (a different agent / different +integration) starts. This began as B6 Phase-5 Tier 0, which scoped the shared +``os.environ`` bridge with a restore token; MT-0a replaced that bridge with a +per-task ContextVar binding in ``acb_skills.integrations``, because the +process-global env could never close the concurrent-overlap window +(saas_multitenancy.md §6.1). + +If a future edit reverts the binding to a process-global store (or drops the +release at any run-teardown site), the accumulation assertion here fails — and +the os.environ assertions catch the specific regression of bridging through +the process env again. + +See specs/saas_multitenancy.md (§6.1, MT-0a) and +specs/permissions_sandbox_b6.md (Phase 5, Tier 0) for the history. """ from __future__ import annotations import os -import orchestrator.executor as ex +import acb_skills.integrations as integrations def test_credentials_do_not_accumulate_across_runs(monkeypatch): @@ -35,39 +39,44 @@ def test_credentials_do_not_accumulate_across_runs(monkeypatch): ] seen_before: set[str] = set() - for integrations, expected_vars in runs: + for creds, expected_vars in runs: # No prior run's secret is visible as this run begins. for var in seen_before: - assert var not in os.environ, ( - f"{var} from a prior run leaked into a later run's env" + assert integrations.credential(var) == "", ( + f"{var} from a prior run leaked into a later run" ) - token = ex._inject_integrations_to_env(integrations) - # This run's own creds are present during the run. + token = integrations.bind_run_credentials(creds) + # This run's own creds are readable during the run... for var in expected_vars: - assert var in os.environ + assert integrations.credential(var) + # ...without ever touching the process-global environment. + assert var not in os.environ, ( + f"{var} was bridged through os.environ — the MT-0a regression" + ) seen_before.update(expected_vars) # Run teardown (the finally / AsyncExitStack callback in the executor). - ex._restore_integration_env(token) + integrations.release_run_credentials(token) # Immediately after teardown, none of this run's creds remain. for var in expected_vars: - assert var not in os.environ + assert integrations.credential(var) == "" def test_operator_env_survives_the_run_lifecycle(monkeypatch): - """A gateway-.env-provided secret is never clobbered nor deleted by scoping.""" + """A gateway-.env-provided secret wins over, and outlives, any run binding.""" monkeypatch.setenv("CLICKUP_API_TOKEN", "operator-value") monkeypatch.delenv("CLICKUP_WORKSPACE_ID", raising=False) - token = ex._inject_integrations_to_env( + token = integrations.bind_run_credentials( {"clickup": {"api_token": "run-value", "workspace_id": "ws"}} ) - # Operator's value wins throughout. - assert os.environ["CLICKUP_API_TOKEN"] == "operator-value" - - ex._restore_integration_env(token) - # ...and still stands after teardown; only the run-scoped var is cleaned. - assert os.environ["CLICKUP_API_TOKEN"] == "operator-value" - assert "CLICKUP_WORKSPACE_ID" not in os.environ + # Operator's value wins throughout; the run-only var reads from the binding. + assert integrations.credential("CLICKUP_API_TOKEN") == "operator-value" + assert integrations.credential("CLICKUP_WORKSPACE_ID") == "ws" + + integrations.release_run_credentials(token) + # ...and still stands after teardown; only the run-scoped var is gone. + assert integrations.credential("CLICKUP_API_TOKEN") == "operator-value" + assert integrations.credential("CLICKUP_WORKSPACE_ID") == "" diff --git a/infra/postgres/157_org_first_party.sql b/infra/postgres/157_org_first_party.sql new file mode 100644 index 000000000..931fa6709 --- /dev/null +++ b/infra/postgres/157_org_first_party.sql @@ -0,0 +1,42 @@ +-- ============================================================================ +-- 157_org_first_party.sql — MT-0b: self-mutation is first-party-only +-- ============================================================================ +-- Spec: ai-company-brain/specs/saas_multitenancy.md §6.2 / MT-0b · board WS-29. +-- +-- Root AGENTS.md non-negotiable 3 has said this since it was written: +-- +-- "native MAF agents (local_path, no own remote) currently land approved +-- self-mutations by opening a PR against THIS Command Center monorepo … +-- It MUST be swapped for a tenant-isolated mechanism before any +-- multi-tenant/customer deployment — third parties must never push to the +-- shared monorepo." +-- +-- A third party's agent failing in production must therefore not be able to +-- open a pull request against Fracktal's repository. The cheapest sufficient +-- containment is a flag that is FALSE by default, so a tenant created tomorrow +-- is contained by construction rather than by someone remembering. +-- +-- `work_plan.md` WS-3 records that no `first_party` field existed anywhere — +-- "the phrase occurs only in comments and one test helper". This migration +-- creates it, as the single source of truth the gate reads. +-- +-- DEFAULT false is the whole point. The existing 'default' org is backfilled to +-- true so today's behaviour is unchanged; every organization created after this +-- migration is contained until a human says otherwise. +-- +-- Idempotent. Depends on: 130_org_access_control.sql (organization). +-- ============================================================================ + +ALTER TABLE organization + ADD COLUMN IF NOT EXISTS first_party BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN organization.first_party IS + 'MT-0b: may this org''s agents land self-mutations as PRs against the ' + 'CommandCenter monorepo? FALSE for every tenant except the operator''s own. ' + 'Read by orchestrator.mutation._self_mutation_permitted, which fails CLOSED.'; + +-- The operator's own organization keeps the behaviour it has today. This is the +-- ONLY row that should ever carry true on a multi-tenant deployment. +UPDATE organization + SET first_party = true + WHERE slug = 'default'; diff --git a/infra/postgres/158_per_org_credentials.sql b/infra/postgres/158_per_org_credentials.sql new file mode 100644 index 000000000..096aad804 --- /dev/null +++ b/infra/postgres/158_per_org_credentials.sql @@ -0,0 +1,140 @@ +-- ============================================================================ +-- 158_per_org_credentials.sql — MT-0d: credentials stop being deployment-wide +-- ============================================================================ +-- Spec: ai-company-brain/specs/saas_multitenancy.md §6.3 / MT-0d · board WS-29. +-- +-- `provider_keys` is `provider TEXT PRIMARY KEY` (08_provider_keys.sql:6-7) — +-- one key per provider for the whole box. `mcp_servers`, `plugins` and +-- `model_config` have no owner column at all. +-- +-- `tenancy_and_visibility.md` §1.1 called that "exactly the right shape", and it +-- WAS: under D11 one deployment served one tenant, so a deployment-wide +-- credential store was correct by construction. **D15 re-took that decision** +-- (2026-08-08), and under a pooled tenant boundary the same shape means tenant +-- B's agent resolves tenant A's OpenAI key. This migration re-keys all four. +-- +-- The lookup side fails CLOSED once a second organization exists: `key_store` +-- and `model_config` resolve an untenanted read to "the sole organization", and +-- there ceases to be one the moment a second is created — see +-- `acb_llm/key_store.py::_resolve_org`. That is deliberate. An untenanted read +-- that kept working after tenant #2 arrived would be the leak this migration +-- exists to prevent, arriving quietly. +-- +-- Idempotent. Depends on: 08_provider_keys.sql, 11_integration_credentials.sql, +-- 13_mcp_servers.sql, 14_plugins.sql, 35_model_config.sql, +-- 130_org_access_control.sql (organization). +-- ============================================================================ + +-- ── Helper: the org every existing row belongs to ─────────────────────────── +-- Every row on this box today is the operator's. Backfill is unconditional. + +-- ── provider_keys — (organization_id, provider) ───────────────────────────── + +ALTER TABLE provider_keys + ADD COLUMN IF NOT EXISTS organization_id UUID + REFERENCES organization(id) ON DELETE CASCADE; + +UPDATE provider_keys + SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM provider_keys WHERE organization_id IS NULL) THEN + RAISE EXCEPTION + 'provider_keys has rows with no organization — refusing to re-key. ' + 'Resolve them before re-running 158.'; + END IF; +END $$; + +ALTER TABLE provider_keys ALTER COLUMN organization_id SET NOT NULL; + +-- Re-point the primary key. Dropping first is safe: the column set is a strict +-- superset, so no duplicate can appear. +ALTER TABLE provider_keys DROP CONSTRAINT IF EXISTS provider_keys_pkey; +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'provider_keys_org_provider_pkey' + ) THEN + ALTER TABLE provider_keys + ADD CONSTRAINT provider_keys_org_provider_pkey + PRIMARY KEY (organization_id, provider); + END IF; +END $$; + +-- org_id FIRST — a distribution key, not a filter column +-- (saas_multitenancy.md §1.8a; retrofitting index order later is expensive). +CREATE INDEX IF NOT EXISTS provider_keys_org_type_idx + ON provider_keys (organization_id, credential_type); + +-- ── model_config — (organization_id, key) ─────────────────────────────────── + +ALTER TABLE model_config + ADD COLUMN IF NOT EXISTS organization_id UUID + REFERENCES organization(id) ON DELETE CASCADE; + +UPDATE model_config + SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +ALTER TABLE model_config ALTER COLUMN organization_id SET NOT NULL; + +ALTER TABLE model_config DROP CONSTRAINT IF EXISTS model_config_pkey; +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'model_config_org_key_pkey' + ) THEN + ALTER TABLE model_config + ADD CONSTRAINT model_config_org_key_pkey + PRIMARY KEY (organization_id, key); + END IF; +END $$; + +-- ── mcp_servers — (organization_id, name) ─────────────────────────────────── + +ALTER TABLE mcp_servers + ADD COLUMN IF NOT EXISTS organization_id UUID + REFERENCES organization(id) ON DELETE CASCADE; + +UPDATE mcp_servers + SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +ALTER TABLE mcp_servers ALTER COLUMN organization_id SET NOT NULL; + +ALTER TABLE mcp_servers DROP CONSTRAINT IF EXISTS mcp_servers_pkey; +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'mcp_servers_org_name_pkey' + ) THEN + ALTER TABLE mcp_servers + ADD CONSTRAINT mcp_servers_org_name_pkey + PRIMARY KEY (organization_id, name); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS mcp_servers_org_enabled_idx + ON mcp_servers (organization_id, enabled); + +-- ── plugins — keeps its UUID pk; the NAME uniqueness becomes per-org ──────── +-- `plugins.id` is already a surrogate UUID, so the PK is fine. What is wrong is +-- `name TEXT UNIQUE` — deployment-wide, so tenant B could not install a plugin +-- tenant A already had, and a lookup by name crosses tenants. + +ALTER TABLE plugins + ADD COLUMN IF NOT EXISTS organization_id UUID + REFERENCES organization(id) ON DELETE CASCADE; + +UPDATE plugins + SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +ALTER TABLE plugins ALTER COLUMN organization_id SET NOT NULL; + +ALTER TABLE plugins DROP CONSTRAINT IF EXISTS plugins_name_key; +CREATE UNIQUE INDEX IF NOT EXISTS plugins_org_name_key + ON plugins (organization_id, name); diff --git a/infra/postgres/159_control_plane.sql b/infra/postgres/159_control_plane.sql new file mode 100644 index 000000000..691c993fd --- /dev/null +++ b/infra/postgres/159_control_plane.sql @@ -0,0 +1,130 @@ +-- ============================================================================ +-- 159_control_plane.sql — MT-1a: the tenant catalog +-- ============================================================================ +-- Spec: ai-company-brain/specs/saas_multitenancy.md §1.5 / §0.9.5 / MT-1a · +-- shapes in saas_multitenancy_implementation.md §3 · board WS-29 · D15. +-- +-- Three things a pooled deployment needs that a single-tenant one never did: +-- +-- tenant_placement WHERE a tenant's data lives. Day one every row resolves +-- to the same target — the INDIRECTION is the point, not +-- the values. It is what turns "move this customer to +-- their own database" (§1.6) into a data move rather than +-- an architecture change, and it is the one mechanism that +-- answers all three of: the silo tier (§1.5), the +-- competitor objection (§1.8a) and version pinning (§1.4b). +-- +-- user_identity One row per HUMAN, globally. Today `app_user` conflates +-- "who is this person" with "what may they do in this +-- org", which is correct for one org and wrong for two: +-- your own support staff need membership in more than one +-- tenant on day two, and partners on day thirty (§1.5). +-- +-- org_membership The tenant-scoped half of that split. +-- +-- ⚠️ ADDITIVE AND INERT. `app_user` is UNTOUCHED and remains the authoritative +-- identity table. Nothing reads these tables yet. Cutting the auth path over is +-- MT-1a-2 and is deliberately NOT bundled here: `acb_auth/access.py` carries +-- two `ON CONFLICT (email)` upserts (`:205`, `:509`) on the live sign-in path, +-- and a half-migrated identity is worse than an unmigrated one. +-- +-- ⚠️ NOT VERIFIED AGAINST A LIVE DATABASE — no Docker daemon was available when +-- this was written. Run it against a scratch Postgres before deploying; +-- `apply_migrations.sh` fails the deploy on error. +-- +-- Idempotent. Depends on: 130_org_access_control.sql (organization). +-- ============================================================================ + +-- ── Placement: which data plane serves this tenant ────────────────────────── + +CREATE TABLE IF NOT EXISTS tenant_placement ( + organization_id UUID PRIMARY KEY REFERENCES organization(id) ON DELETE CASCADE, + -- pool — shared Postgres, RLS-isolated (the standard tier, ~95% of customers) + -- bridge — own database or schema, shared app fleet (compliance-sensitive) + -- silo — own everything (enterprise, regulated, data residency) + tier TEXT NOT NULL DEFAULT 'pool' + CHECK (tier IN ('pool', 'bridge', 'silo')), + -- A connection ALIAS the app resolves, never a raw URL with a password in + -- it. A credential in this column would be a credential in every backup. + target TEXT NOT NULL DEFAULT 'primary', + region TEXT NOT NULL DEFAULT 'ap-south-1', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE tenant_placement IS + 'MT-1a: which data plane serves each tenant. Day one every row is ' + '(pool, primary) — the indirection is what makes a later move a data move.'; + +-- Every existing organization is placed. A tenant with no placement row is +-- unresolvable, so this must never be allowed to drift. +INSERT INTO tenant_placement (organization_id, tier, target) +SELECT id, 'pool', 'primary' FROM organization +ON CONFLICT (organization_id) DO NOTHING; + +-- ── Identity: one row per human, across all tenants ───────────────────────── + +CREATE TABLE IF NOT EXISTS user_identity ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Globally unique, and case-insensitively so: an IdP that changes UPN + -- casing between sessions must not mint a second human + -- (user_management_contract.md R10, learned by breaking it). + email TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS user_identity_email_key + ON user_identity (lower(email)); + +-- ── Membership: the tenant-scoped half ────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS org_membership ( + organization_id UUID NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES user_identity(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('invited', 'active', 'suspended', 'removed')), + invited_by TEXT, + invited_at TIMESTAMPTZ, + joined_at TIMESTAMPTZ, + last_active_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- organization_id FIRST — a distribution key, not a filter column (§1.8a). + -- Retrofitting key order after data exists means rewriting every FK. + PRIMARY KEY (organization_id, user_id) +); + +CREATE INDEX IF NOT EXISTS org_membership_user_idx + ON org_membership (user_id); + +COMMENT ON TABLE org_membership IS + 'MT-1a: a human''s membership in ONE tenant. Splitting this out of app_user ' + 'is what lets one person belong to two organizations — support staff need ' + 'it on day two, partners on day thirty.'; + +-- ── Seed from app_user, WITHOUT changing app_user ─────────────────────────── +-- Mirrors today's members so the tables are populated and inspectable from the +-- moment they exist. app_user remains authoritative until MT-1a-2 cuts the auth +-- path over; this is a shadow copy, not a replacement. + +-- NOTE: the column is `display_name` (09_app_user.sql:16), not `name`. Verified +-- against schema.generated.sql:1579 — an earlier draft of this migration used +-- `u.name` and would have failed on apply. +INSERT INTO user_identity (email, display_name) +SELECT DISTINCT ON (lower(u.email)) u.email, COALESCE(u.display_name, '') + FROM app_user u + WHERE u.email IS NOT NULL AND u.email <> '' + ORDER BY lower(u.email), u.created_at ASC +ON CONFLICT DO NOTHING; + +INSERT INTO org_membership (organization_id, user_id, status, joined_at, last_active_at) +SELECT u.organization_id, + i.id, + COALESCE(u.status, 'active'), + COALESCE(u.joined_at, u.created_at), + u.last_active_at + FROM app_user u + JOIN user_identity i ON lower(i.email) = lower(u.email) + WHERE u.organization_id IS NOT NULL +ON CONFLICT (organization_id, user_id) DO NOTHING; diff --git a/infra/postgres/generated/01_add_columns.sql b/infra/postgres/generated/01_add_columns.sql new file mode 100644 index 000000000..00577f28b --- /dev/null +++ b/infra/postgres/generated/01_add_columns.sql @@ -0,0 +1,556 @@ +-- ============================================================================ +-- MT-1b · phase 1/4 add_columns — GENERATED, DO NOT EDIT BY HAND +-- ============================================================================ +-- Regenerate with: uv run python scripts/gen_tenant_migration.py +-- Spec: ai-company-brain/specs/saas_multitenancy.md §1.3 · MT-1b · WS-29 · D15 +-- +-- Nullable ADD COLUMN. No table scan, no lock of consequence. Safe to apply on a live system. +-- +-- Tables in this phase: 135 +-- +-- ⚠️ 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 +-- outage that makes that non-negotiable. +-- ============================================================================ + + +ALTER TABLE access_request + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE action_item + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE agent_avatars + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE agent_blob + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE agent_file_history + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE agent_run + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE agent_skill_setting + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE app_audit + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE app_data + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE app_files + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE app_grants + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE app_pins + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE app_tool_grants + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE app_user + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE app_versions + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE apps + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE audit_event + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE chat_message + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE chat_session + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE chat_session_agent + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE chat_session_participant + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE copilot_config + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +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; + +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; + +ALTER TABLE crm_leads + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE crm_lost_reasons + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE crm_organizations + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE crm_status_changes + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE crm_sync_cursors + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE crm_zoho_tombstones + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE custom_api_definitions + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE customer + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE deal + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE dynamic_agents + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_accounts + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_actions + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_ai_drafts + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_assistant_settings + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_attachments + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_cold_senders + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_contacts + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_embeddings + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_executed_rules + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_folders + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_knowledge + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_learned_patterns + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_messages + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_newsletters + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_rule_guidance + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_rule_patterns + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_rules + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_senders + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_sync_log + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_thread_status + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE email_voice_profiles + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_attachments + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_contexts + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_day_state + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_folders + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_horizons + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_items + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_people + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_person_resumes + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_projects + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_reviews + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_rollover_log + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_settings + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_spaces + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE gtd_waiting + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE if + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE live_session + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE meeting + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE meeting_bot + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE meeting_note + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE meeting_recording + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE message + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE notes_glossary + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE org_group_member + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE org_role_permission + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE org_settings + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pending_actions + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pending_commit + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE person + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE plugins + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_activities + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_custom_fields + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_notifications + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_project_grants + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_projects + 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; + +ALTER TABLE pm_task_assignees + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_task_attachments + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_task_counters + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_task_links + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_task_personal + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_task_statuses + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_task_types + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_tasks + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_view_task_positions + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE pm_views + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE project + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE summary_run + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE task + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE task_accounts + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE transcript_segment + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE user_permission_override + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE user_role + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_accounts + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_ai_drafts + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_categories + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_chat_avatars + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_chat_labels + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_chat_status + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_chats + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_commitments + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_contacts + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_group_summaries + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_labels + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_media + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_message_embeddings + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_messages + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_saved_replies + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_sync_log + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE wa_templates + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE workflow_modules + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE workflow_run_pauses + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE workflow_runs + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE workflow_triggers + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE workflow_versions + ADD COLUMN IF NOT EXISTS organization_id UUID + DEFAULT current_setting('app.tenant_id', true)::uuid; + +ALTER TABLE workflows + 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 new file mode 100644 index 000000000..e3be42565 --- /dev/null +++ b/infra/postgres/generated/02_backfill.sql @@ -0,0 +1,424 @@ +-- ============================================================================ +-- MT-1b · phase 2/4 backfill — GENERATED, DO NOT EDIT BY HAND +-- ============================================================================ +-- Regenerate with: uv run python scripts/gen_tenant_migration.py +-- Spec: ai-company-brain/specs/saas_multitenancy.md §1.3 · MT-1b · WS-29 · D15 +-- +-- 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 +-- +-- ⚠️ 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 +-- outage that makes that non-negotiable. +-- ============================================================================ + + +-- The operator's own organization owns every pre-existing row: this box +-- served exactly one tenant before MT-1b. + +UPDATE access_request SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE action_item SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE agent_avatars SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE agent_blob SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE agent_file_history SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE agent_run SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE agent_skill_setting SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE app_audit SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE app_data SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE app_files SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE app_grants SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE app_pins SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE app_tool_grants SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE app_user SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE app_versions SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE apps SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE audit_event SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE chat_message SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE chat_session SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE chat_session_agent SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE chat_session_participant SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE copilot_config SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +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; + +UPDATE crm_leads SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE crm_lost_reasons SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE crm_organizations SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE crm_status_changes SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE crm_sync_cursors SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE crm_zoho_tombstones SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE custom_api_definitions SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE customer SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE deal SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE dynamic_agents SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_accounts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_actions SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_ai_drafts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_assistant_settings SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_attachments SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_cold_senders SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_contacts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_embeddings SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_executed_rules SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_folders SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_knowledge SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_learned_patterns SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_messages SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_newsletters SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_rule_guidance SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_rule_patterns SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_rules SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_senders SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_sync_log SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_thread_status SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE email_voice_profiles SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_attachments SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_contexts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_day_state SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_folders SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_horizons SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_items SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_people SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_person_resumes SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_projects SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_reviews SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_rollover_log SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_settings SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_spaces SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE gtd_waiting SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE if SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE live_session SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE meeting SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE meeting_bot SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE meeting_note SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE meeting_recording SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE message SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE notes_glossary SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE org_group_member SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE org_role_permission SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE org_settings SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE pending_actions SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE pending_commit SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE person SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE plugins 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_custom_fields 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_project_grants SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE pm_projects 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_task_assignees 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_task_counters 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_task_personal 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_tasks 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_views SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE project SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE summary_run SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE task SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE task_accounts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE transcript_segment SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE user_permission_override SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE user_role SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_accounts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_ai_drafts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_categories SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_chat_avatars SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_chat_labels SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_chat_status SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_chats SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_commitments SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_contacts SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_group_summaries SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_labels SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_media SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_message_embeddings SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_messages SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_saved_replies SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_sync_log SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE wa_templates SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE workflow_modules SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE workflow_run_pauses SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE workflow_runs SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE workflow_triggers SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE workflow_versions SET organization_id = (SELECT id FROM organization WHERE slug = 'default') + WHERE organization_id IS NULL; + +UPDATE workflows 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 new file mode 100644 index 000000000..6c7dc620c --- /dev/null +++ b/infra/postgres/generated/03_constraints.sql @@ -0,0 +1,1636 @@ +-- ============================================================================ +-- MT-1b · phase 3/4 constraints — GENERATED, DO NOT EDIT BY HAND +-- ============================================================================ +-- Regenerate with: uv run python scripts/gen_tenant_migration.py +-- Spec: ai-company-brain/specs/saas_multitenancy.md §1.3 · MT-1b · WS-29 · D15 +-- +-- 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 +-- +-- ⚠️ 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 +-- outage that makes that non-negotiable. +-- ============================================================================ + + +-- access_request +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM access_request WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: access_request still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE access_request ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE access_request ADD CONSTRAINT access_request_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS access_request_org_idx ON access_request (organization_id); + +-- action_item +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM action_item WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: action_item still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE action_item ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE action_item ADD CONSTRAINT action_item_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS action_item_org_idx ON action_item (organization_id); + +-- agent_avatars +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM agent_avatars WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: agent_avatars still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE agent_avatars ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE agent_avatars ADD CONSTRAINT agent_avatars_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS agent_avatars_org_idx ON agent_avatars (organization_id); + +-- agent_blob +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM agent_blob WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: agent_blob still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE agent_blob ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE agent_blob ADD CONSTRAINT agent_blob_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS agent_blob_org_idx ON agent_blob (organization_id); + +-- agent_file_history +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM agent_file_history WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: agent_file_history still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE agent_file_history ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE agent_file_history ADD CONSTRAINT agent_file_history_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS agent_file_history_org_idx ON agent_file_history (organization_id); + +-- agent_run +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM agent_run WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: agent_run still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE agent_run ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE agent_run ADD CONSTRAINT agent_run_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS agent_run_org_idx ON agent_run (organization_id); + +-- agent_skill_setting +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM agent_skill_setting WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: agent_skill_setting still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE agent_skill_setting ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE agent_skill_setting ADD CONSTRAINT agent_skill_setting_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS agent_skill_setting_org_idx ON agent_skill_setting (organization_id); + +-- app_audit +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM app_audit WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: app_audit still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE app_audit ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE app_audit ADD CONSTRAINT app_audit_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS app_audit_org_idx ON app_audit (organization_id); + +-- app_data +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM app_data WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: app_data still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE app_data ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE app_data ADD CONSTRAINT app_data_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS app_data_org_idx ON app_data (organization_id); + +-- app_files +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM app_files WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: app_files still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE app_files ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE app_files ADD CONSTRAINT app_files_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS app_files_org_idx ON app_files (organization_id); + +-- app_grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM app_grants WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: app_grants still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE app_grants ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE app_grants ADD CONSTRAINT app_grants_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS app_grants_org_idx ON app_grants (organization_id); + +-- app_pins +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM app_pins WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: app_pins still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE app_pins ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE app_pins ADD CONSTRAINT app_pins_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS app_pins_org_idx ON app_pins (organization_id); + +-- app_tool_grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM app_tool_grants WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: app_tool_grants still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE app_tool_grants ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE app_tool_grants ADD CONSTRAINT app_tool_grants_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS app_tool_grants_org_idx ON app_tool_grants (organization_id); + +-- app_user +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM app_user WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: app_user still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE app_user ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE app_user ADD CONSTRAINT app_user_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS app_user_org_idx ON app_user (organization_id); + +-- app_versions +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM app_versions WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: app_versions still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE app_versions ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE app_versions ADD CONSTRAINT app_versions_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS app_versions_org_idx ON app_versions (organization_id); + +-- apps +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM apps WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: apps still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE apps ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE apps ADD CONSTRAINT apps_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS apps_org_idx ON apps (organization_id); + +-- audit_event +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM audit_event WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: audit_event still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE audit_event ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE audit_event ADD CONSTRAINT audit_event_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS audit_event_org_idx ON audit_event (organization_id); + +-- chat_message +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM chat_message WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: chat_message still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE chat_message ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE chat_message ADD CONSTRAINT chat_message_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS chat_message_org_idx ON chat_message (organization_id); + +-- chat_session +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM chat_session WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: chat_session still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE chat_session ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE chat_session ADD CONSTRAINT chat_session_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS chat_session_org_idx ON chat_session (organization_id); + +-- chat_session_agent +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM chat_session_agent WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: chat_session_agent still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE chat_session_agent ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE chat_session_agent ADD CONSTRAINT chat_session_agent_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS chat_session_agent_org_idx ON chat_session_agent (organization_id); + +-- chat_session_participant +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM chat_session_participant WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: chat_session_participant still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE chat_session_participant ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE chat_session_participant ADD CONSTRAINT chat_session_participant_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS chat_session_participant_org_idx ON chat_session_participant (organization_id); + +-- copilot_config +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM copilot_config WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: copilot_config still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE copilot_config ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE copilot_config ADD CONSTRAINT copilot_config_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS copilot_config_org_idx ON copilot_config (organization_id); + +-- copilot_event +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM copilot_event WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: copilot_event still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE copilot_event ALTER COLUMN organization_id SET NOT NULL; +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 + IF EXISTS (SELECT 1 FROM crm_deal_contacts WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: crm_deal_contacts still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE crm_deal_contacts ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE crm_deal_contacts ADD CONSTRAINT crm_deal_contacts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS crm_deal_contacts_org_idx ON crm_deal_contacts (organization_id); + +-- crm_deal_statuses +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM crm_deal_statuses WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: crm_deal_statuses still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE crm_deal_statuses ALTER COLUMN organization_id SET NOT NULL; +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 + IF EXISTS (SELECT 1 FROM crm_lead_statuses WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: crm_lead_statuses still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE crm_lead_statuses ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE crm_lead_statuses ADD CONSTRAINT crm_lead_statuses_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS crm_lead_statuses_org_idx ON crm_lead_statuses (organization_id); + +-- crm_leads +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM crm_leads WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: crm_leads still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE crm_leads ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE crm_leads ADD CONSTRAINT crm_leads_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS crm_leads_org_idx ON crm_leads (organization_id); + +-- crm_lost_reasons +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM crm_lost_reasons WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: crm_lost_reasons still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE crm_lost_reasons ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE crm_lost_reasons ADD CONSTRAINT crm_lost_reasons_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS crm_lost_reasons_org_idx ON crm_lost_reasons (organization_id); + +-- crm_organizations +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM crm_organizations WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: crm_organizations still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE crm_organizations ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE crm_organizations ADD CONSTRAINT crm_organizations_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS crm_organizations_org_idx ON crm_organizations (organization_id); + +-- crm_status_changes +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM crm_status_changes WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: crm_status_changes still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE crm_status_changes ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE crm_status_changes ADD CONSTRAINT crm_status_changes_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS crm_status_changes_org_idx ON crm_status_changes (organization_id); + +-- crm_sync_cursors +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM crm_sync_cursors WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: crm_sync_cursors still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE crm_sync_cursors ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE crm_sync_cursors ADD CONSTRAINT crm_sync_cursors_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS crm_sync_cursors_org_idx ON crm_sync_cursors (organization_id); + +-- crm_zoho_tombstones +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM crm_zoho_tombstones WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: crm_zoho_tombstones still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE crm_zoho_tombstones ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE crm_zoho_tombstones ADD CONSTRAINT crm_zoho_tombstones_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS crm_zoho_tombstones_org_idx ON crm_zoho_tombstones (organization_id); + +-- custom_api_definitions +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM custom_api_definitions WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: custom_api_definitions still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE custom_api_definitions ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE custom_api_definitions ADD CONSTRAINT custom_api_definitions_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS custom_api_definitions_org_idx ON custom_api_definitions (organization_id); + +-- customer +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM customer WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: customer still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE customer ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE customer ADD CONSTRAINT customer_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS customer_org_idx ON customer (organization_id); + +-- deal +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM deal WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: deal still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE deal ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE deal ADD CONSTRAINT deal_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS deal_org_idx ON deal (organization_id); + +-- dynamic_agents +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM dynamic_agents WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: dynamic_agents still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE dynamic_agents ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE dynamic_agents ADD CONSTRAINT dynamic_agents_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS dynamic_agents_org_idx ON dynamic_agents (organization_id); + +-- email_accounts +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_accounts WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_accounts still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_accounts ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_accounts ADD CONSTRAINT email_accounts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_accounts_org_idx ON email_accounts (organization_id); + +-- email_actions +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_actions WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_actions still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_actions ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_actions ADD CONSTRAINT email_actions_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_actions_org_idx ON email_actions (organization_id); + +-- email_ai_drafts +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_ai_drafts WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_ai_drafts still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_ai_drafts ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_ai_drafts ADD CONSTRAINT email_ai_drafts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_ai_drafts_org_idx ON email_ai_drafts (organization_id); + +-- email_assistant_settings +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_assistant_settings WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_assistant_settings still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_assistant_settings ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_assistant_settings ADD CONSTRAINT email_assistant_settings_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_assistant_settings_org_idx ON email_assistant_settings (organization_id); + +-- email_attachments +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_attachments WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_attachments still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_attachments ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_attachments ADD CONSTRAINT email_attachments_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_attachments_org_idx ON email_attachments (organization_id); + +-- email_cold_senders +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_cold_senders WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_cold_senders still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_cold_senders ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_cold_senders ADD CONSTRAINT email_cold_senders_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_cold_senders_org_idx ON email_cold_senders (organization_id); + +-- email_contacts +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_contacts WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_contacts still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_contacts ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_contacts ADD CONSTRAINT email_contacts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_contacts_org_idx ON email_contacts (organization_id); + +-- email_embeddings +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_embeddings WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_embeddings still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_embeddings ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_embeddings ADD CONSTRAINT email_embeddings_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_embeddings_org_idx ON email_embeddings (organization_id); + +-- email_executed_rules +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_executed_rules WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_executed_rules still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_executed_rules ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_executed_rules ADD CONSTRAINT email_executed_rules_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_executed_rules_org_idx ON email_executed_rules (organization_id); + +-- email_folders +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_folders WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_folders still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_folders ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_folders ADD CONSTRAINT email_folders_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_folders_org_idx ON email_folders (organization_id); + +-- email_knowledge +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_knowledge WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_knowledge still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_knowledge ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_knowledge ADD CONSTRAINT email_knowledge_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_knowledge_org_idx ON email_knowledge (organization_id); + +-- email_learned_patterns +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_learned_patterns WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_learned_patterns still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_learned_patterns ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_learned_patterns ADD CONSTRAINT email_learned_patterns_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_learned_patterns_org_idx ON email_learned_patterns (organization_id); + +-- email_messages +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_messages WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_messages still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_messages ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_messages ADD CONSTRAINT email_messages_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_messages_org_idx ON email_messages (organization_id); + +-- email_newsletters +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_newsletters WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_newsletters still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_newsletters ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_newsletters ADD CONSTRAINT email_newsletters_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_newsletters_org_idx ON email_newsletters (organization_id); + +-- email_rule_guidance +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_rule_guidance WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_rule_guidance still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_rule_guidance ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_rule_guidance ADD CONSTRAINT email_rule_guidance_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_rule_guidance_org_idx ON email_rule_guidance (organization_id); + +-- email_rule_patterns +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_rule_patterns WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_rule_patterns still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_rule_patterns ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_rule_patterns ADD CONSTRAINT email_rule_patterns_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_rule_patterns_org_idx ON email_rule_patterns (organization_id); + +-- email_rules +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_rules WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_rules still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_rules ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_rules ADD CONSTRAINT email_rules_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_rules_org_idx ON email_rules (organization_id); + +-- email_senders +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_senders WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_senders still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_senders ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_senders ADD CONSTRAINT email_senders_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_senders_org_idx ON email_senders (organization_id); + +-- email_sync_log +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_sync_log WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_sync_log still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_sync_log ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_sync_log ADD CONSTRAINT email_sync_log_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_sync_log_org_idx ON email_sync_log (organization_id); + +-- email_thread_status +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_thread_status WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_thread_status still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_thread_status ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_thread_status ADD CONSTRAINT email_thread_status_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_thread_status_org_idx ON email_thread_status (organization_id); + +-- email_voice_profiles +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM email_voice_profiles WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: email_voice_profiles still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE email_voice_profiles ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE email_voice_profiles ADD CONSTRAINT email_voice_profiles_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS email_voice_profiles_org_idx ON email_voice_profiles (organization_id); + +-- gtd_attachments +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_attachments WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_attachments still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_attachments ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_attachments ADD CONSTRAINT gtd_attachments_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_attachments_org_idx ON gtd_attachments (organization_id); + +-- gtd_contexts +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_contexts WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_contexts still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_contexts ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_contexts ADD CONSTRAINT gtd_contexts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_contexts_org_idx ON gtd_contexts (organization_id); + +-- gtd_day_state +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_day_state WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_day_state still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_day_state ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_day_state ADD CONSTRAINT gtd_day_state_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_day_state_org_idx ON gtd_day_state (organization_id); + +-- gtd_folders +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_folders WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_folders still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_folders ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_folders ADD CONSTRAINT gtd_folders_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_folders_org_idx ON gtd_folders (organization_id); + +-- gtd_horizons +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_horizons WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_horizons still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_horizons ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_horizons ADD CONSTRAINT gtd_horizons_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_horizons_org_idx ON gtd_horizons (organization_id); + +-- gtd_items +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_items WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_items still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_items ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_items ADD CONSTRAINT gtd_items_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_items_org_idx ON gtd_items (organization_id); + +-- gtd_people +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_people WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_people still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_people ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_people ADD CONSTRAINT gtd_people_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_people_org_idx ON gtd_people (organization_id); + +-- gtd_person_resumes +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_person_resumes WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_person_resumes still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_person_resumes ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_person_resumes ADD CONSTRAINT gtd_person_resumes_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_person_resumes_org_idx ON gtd_person_resumes (organization_id); + +-- gtd_projects +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_projects WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_projects still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_projects ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_projects ADD CONSTRAINT gtd_projects_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_projects_org_idx ON gtd_projects (organization_id); + +-- gtd_reviews +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_reviews WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_reviews still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_reviews ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_reviews ADD CONSTRAINT gtd_reviews_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_reviews_org_idx ON gtd_reviews (organization_id); + +-- gtd_rollover_log +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_rollover_log WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_rollover_log still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_rollover_log ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_rollover_log ADD CONSTRAINT gtd_rollover_log_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_rollover_log_org_idx ON gtd_rollover_log (organization_id); + +-- gtd_settings +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_settings WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_settings still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_settings ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_settings ADD CONSTRAINT gtd_settings_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_settings_org_idx ON gtd_settings (organization_id); + +-- gtd_spaces +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_spaces WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_spaces still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_spaces ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_spaces ADD CONSTRAINT gtd_spaces_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_spaces_org_idx ON gtd_spaces (organization_id); + +-- gtd_waiting +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM gtd_waiting WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: gtd_waiting still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE gtd_waiting ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE gtd_waiting ADD CONSTRAINT gtd_waiting_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS gtd_waiting_org_idx ON gtd_waiting (organization_id); + +-- if +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM if WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: if still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE if ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE if ADD CONSTRAINT if_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS if_org_idx ON if (organization_id); + +-- live_session +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM live_session WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: live_session still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE live_session ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE live_session ADD CONSTRAINT live_session_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS live_session_org_idx ON live_session (organization_id); + +-- meeting +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM meeting WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: meeting still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE meeting ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE meeting ADD CONSTRAINT meeting_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS meeting_org_idx ON meeting (organization_id); + +-- meeting_bot +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM meeting_bot WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: meeting_bot still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE meeting_bot ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE meeting_bot ADD CONSTRAINT meeting_bot_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS meeting_bot_org_idx ON meeting_bot (organization_id); + +-- meeting_note +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM meeting_note WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: meeting_note still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE meeting_note ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE meeting_note ADD CONSTRAINT meeting_note_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS meeting_note_org_idx ON meeting_note (organization_id); + +-- meeting_recording +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM meeting_recording WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: meeting_recording still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE meeting_recording ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE meeting_recording ADD CONSTRAINT meeting_recording_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS meeting_recording_org_idx ON meeting_recording (organization_id); + +-- message +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM message WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: message still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE message ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE message ADD CONSTRAINT message_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS message_org_idx ON message (organization_id); + +-- notes_glossary +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM notes_glossary WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: notes_glossary still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE notes_glossary ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE notes_glossary ADD CONSTRAINT notes_glossary_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS notes_glossary_org_idx ON notes_glossary (organization_id); + +-- org_group_member +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM org_group_member WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: org_group_member still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE org_group_member ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE org_group_member ADD CONSTRAINT org_group_member_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS org_group_member_org_idx ON org_group_member (organization_id); + +-- org_role_permission +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM org_role_permission WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: org_role_permission still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE org_role_permission ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE org_role_permission ADD CONSTRAINT org_role_permission_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS org_role_permission_org_idx ON org_role_permission (organization_id); + +-- org_settings +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM org_settings WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: org_settings still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE org_settings ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE org_settings ADD CONSTRAINT org_settings_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS org_settings_org_idx ON org_settings (organization_id); + +-- pending_actions +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pending_actions WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pending_actions still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pending_actions ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pending_actions ADD CONSTRAINT pending_actions_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pending_actions_org_idx ON pending_actions (organization_id); + +-- pending_commit +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pending_commit WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pending_commit still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pending_commit ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pending_commit ADD CONSTRAINT pending_commit_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pending_commit_org_idx ON pending_commit (organization_id); + +-- person +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM person WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: person still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE person ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE person ADD CONSTRAINT person_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS person_org_idx ON person (organization_id); + +-- plugins +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM plugins WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: plugins still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE plugins ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE plugins ADD CONSTRAINT plugins_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS plugins_org_idx ON plugins (organization_id); + +-- pm_activities +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_activities WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_activities still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_activities ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_activities ADD CONSTRAINT pm_activities_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_activities_org_idx ON pm_activities (organization_id); + +-- pm_custom_fields +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_custom_fields WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_custom_fields still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_custom_fields ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_custom_fields ADD CONSTRAINT pm_custom_fields_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_custom_fields_org_idx ON pm_custom_fields (organization_id); + +-- pm_notifications +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_notifications WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_notifications still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_notifications ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_notifications ADD CONSTRAINT pm_notifications_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_notifications_org_idx ON pm_notifications (organization_id); + +-- pm_project_grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_project_grants WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_project_grants still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_project_grants ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_project_grants ADD CONSTRAINT pm_project_grants_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_project_grants_org_idx ON pm_project_grants (organization_id); + +-- pm_projects +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_projects WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_projects still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_projects ALTER COLUMN organization_id SET NOT NULL; +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_tags +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_tags WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_tags still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_tags ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_tags ADD CONSTRAINT pm_tags_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_tags_org_idx ON pm_tags (organization_id); + +-- pm_task_assignees +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_task_assignees WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_task_assignees still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_task_assignees ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_assignees ADD CONSTRAINT pm_task_assignees_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_task_assignees_org_idx ON pm_task_assignees (organization_id); + +-- pm_task_attachments +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_task_attachments WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_task_attachments still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_task_attachments ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_attachments ADD CONSTRAINT pm_task_attachments_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_task_attachments_org_idx ON pm_task_attachments (organization_id); + +-- pm_task_counters +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_task_counters WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_task_counters still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_task_counters ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_counters ADD CONSTRAINT pm_task_counters_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_task_counters_org_idx ON pm_task_counters (organization_id); + +-- pm_task_links +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_task_links WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_task_links still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_task_links ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_links ADD CONSTRAINT pm_task_links_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_task_links_org_idx ON pm_task_links (organization_id); + +-- pm_task_personal +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_task_personal WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_task_personal still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_task_personal ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_personal ADD CONSTRAINT pm_task_personal_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_task_personal_org_idx ON pm_task_personal (organization_id); + +-- pm_task_statuses +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_task_statuses WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_task_statuses still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_task_statuses ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_statuses ADD CONSTRAINT pm_task_statuses_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_task_statuses_org_idx ON pm_task_statuses (organization_id); + +-- pm_task_types +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_task_types WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_task_types still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_task_types ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_task_types ADD CONSTRAINT pm_task_types_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_task_types_org_idx ON pm_task_types (organization_id); + +-- pm_tasks +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_tasks WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_tasks still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_tasks ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_tasks ADD CONSTRAINT pm_tasks_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_tasks_org_idx ON pm_tasks (organization_id); + +-- pm_view_task_positions +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_view_task_positions WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_view_task_positions still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_view_task_positions ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_view_task_positions ADD CONSTRAINT pm_view_task_positions_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_view_task_positions_org_idx ON pm_view_task_positions (organization_id); + +-- pm_views +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pm_views WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: pm_views still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE pm_views ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE pm_views ADD CONSTRAINT pm_views_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS pm_views_org_idx ON pm_views (organization_id); + +-- project +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM project WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: project still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE project ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE project ADD CONSTRAINT project_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS project_org_idx ON project (organization_id); + +-- summary_run +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM summary_run WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: summary_run still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE summary_run ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE summary_run ADD CONSTRAINT summary_run_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS summary_run_org_idx ON summary_run (organization_id); + +-- task +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM task WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: task still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE task ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE task ADD CONSTRAINT task_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS task_org_idx ON task (organization_id); + +-- task_accounts +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM task_accounts WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: task_accounts still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE task_accounts ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE task_accounts ADD CONSTRAINT task_accounts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS task_accounts_org_idx ON task_accounts (organization_id); + +-- transcript_segment +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM transcript_segment WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: transcript_segment still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE transcript_segment ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE transcript_segment ADD CONSTRAINT transcript_segment_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS transcript_segment_org_idx ON transcript_segment (organization_id); + +-- user_permission_override +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM user_permission_override WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: user_permission_override still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE user_permission_override ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE user_permission_override ADD CONSTRAINT user_permission_override_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS user_permission_override_org_idx ON user_permission_override (organization_id); + +-- user_role +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM user_role WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: user_role still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE user_role ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE user_role ADD CONSTRAINT user_role_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS user_role_org_idx ON user_role (organization_id); + +-- wa_accounts +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_accounts WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_accounts still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_accounts ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_accounts ADD CONSTRAINT wa_accounts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_accounts_org_idx ON wa_accounts (organization_id); + +-- wa_ai_drafts +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_ai_drafts WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_ai_drafts still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_ai_drafts ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_ai_drafts ADD CONSTRAINT wa_ai_drafts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_ai_drafts_org_idx ON wa_ai_drafts (organization_id); + +-- wa_categories +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_categories WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_categories still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_categories ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_categories ADD CONSTRAINT wa_categories_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_categories_org_idx ON wa_categories (organization_id); + +-- wa_chat_avatars +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_chat_avatars WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_chat_avatars still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_chat_avatars ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_chat_avatars ADD CONSTRAINT wa_chat_avatars_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_chat_avatars_org_idx ON wa_chat_avatars (organization_id); + +-- wa_chat_labels +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_chat_labels WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_chat_labels still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_chat_labels ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_chat_labels ADD CONSTRAINT wa_chat_labels_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_chat_labels_org_idx ON wa_chat_labels (organization_id); + +-- wa_chat_status +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_chat_status WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_chat_status still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_chat_status ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_chat_status ADD CONSTRAINT wa_chat_status_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_chat_status_org_idx ON wa_chat_status (organization_id); + +-- wa_chats +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_chats WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_chats still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_chats ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_chats ADD CONSTRAINT wa_chats_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_chats_org_idx ON wa_chats (organization_id); + +-- wa_commitments +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_commitments WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_commitments still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_commitments ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_commitments ADD CONSTRAINT wa_commitments_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_commitments_org_idx ON wa_commitments (organization_id); + +-- wa_contacts +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_contacts WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_contacts still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_contacts ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_contacts ADD CONSTRAINT wa_contacts_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_contacts_org_idx ON wa_contacts (organization_id); + +-- wa_group_summaries +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_group_summaries WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_group_summaries still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_group_summaries ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_group_summaries ADD CONSTRAINT wa_group_summaries_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_group_summaries_org_idx ON wa_group_summaries (organization_id); + +-- wa_labels +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_labels WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_labels still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_labels ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_labels ADD CONSTRAINT wa_labels_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_labels_org_idx ON wa_labels (organization_id); + +-- wa_media +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_media WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_media still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_media ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_media ADD CONSTRAINT wa_media_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_media_org_idx ON wa_media (organization_id); + +-- wa_message_embeddings +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_message_embeddings WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_message_embeddings still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_message_embeddings ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_message_embeddings ADD CONSTRAINT wa_message_embeddings_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_message_embeddings_org_idx ON wa_message_embeddings (organization_id); + +-- wa_messages +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_messages WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_messages still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_messages ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_messages ADD CONSTRAINT wa_messages_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_messages_org_idx ON wa_messages (organization_id); + +-- wa_saved_replies +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_saved_replies WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_saved_replies still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_saved_replies ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_saved_replies ADD CONSTRAINT wa_saved_replies_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_saved_replies_org_idx ON wa_saved_replies (organization_id); + +-- wa_sync_log +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_sync_log WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_sync_log still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_sync_log ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_sync_log ADD CONSTRAINT wa_sync_log_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_sync_log_org_idx ON wa_sync_log (organization_id); + +-- wa_templates +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM wa_templates WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: wa_templates still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE wa_templates ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE wa_templates ADD CONSTRAINT wa_templates_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS wa_templates_org_idx ON wa_templates (organization_id); + +-- workflow_modules +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM workflow_modules WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: workflow_modules still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE workflow_modules ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE workflow_modules ADD CONSTRAINT workflow_modules_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS workflow_modules_org_idx ON workflow_modules (organization_id); + +-- workflow_run_pauses +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM workflow_run_pauses WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: workflow_run_pauses still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE workflow_run_pauses ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE workflow_run_pauses ADD CONSTRAINT workflow_run_pauses_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS workflow_run_pauses_org_idx ON workflow_run_pauses (organization_id); + +-- workflow_runs +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM workflow_runs WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: workflow_runs still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE workflow_runs ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE workflow_runs ADD CONSTRAINT workflow_runs_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS workflow_runs_org_idx ON workflow_runs (organization_id); + +-- workflow_triggers +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM workflow_triggers WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: workflow_triggers still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE workflow_triggers ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE workflow_triggers ADD CONSTRAINT workflow_triggers_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS workflow_triggers_org_idx ON workflow_triggers (organization_id); + +-- workflow_versions +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM workflow_versions WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: workflow_versions still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE workflow_versions ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE workflow_versions ADD CONSTRAINT workflow_versions_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS workflow_versions_org_idx ON workflow_versions (organization_id); + +-- workflows +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM workflows WHERE organization_id IS NULL) THEN + RAISE EXCEPTION 'MT-1b: workflows still has unowned rows — run phase 2 (backfill) to completion first'; + END IF; +END $$; +ALTER TABLE workflows ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE workflows ADD CONSTRAINT workflows_org_fk + FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS workflows_org_idx ON workflows (organization_id); diff --git a/infra/postgres/generated/04_policies.sql b/infra/postgres/generated/04_policies.sql new file mode 100644 index 000000000..9bc6f21a8 --- /dev/null +++ b/infra/postgres/generated/04_policies.sql @@ -0,0 +1,972 @@ +-- ============================================================================ +-- MT-1b · phase 4/4 policies — GENERATED, DO NOT EDIT BY HAND +-- ============================================================================ +-- Regenerate with: uv run python scripts/gen_tenant_migration.py +-- Spec: ai-company-brain/specs/saas_multitenancy.md §1.3 · MT-1b · WS-29 · D15 +-- +-- 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 +-- +-- ⚠️ 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 +-- outage that makes that non-negotiable. +-- ============================================================================ + + +-- Four clauses, each load-bearing (saas_multitenancy_implementation.md §1.1): +-- ENABLE turns the policy on for ordinary roles +-- FORCE applies it to the table OWNER too — without this the +-- owner silently reads every tenant +-- USING filters what a query can SEE +-- WITH CHECK constrains what it can WRITE. Without it a tenant can +-- INSERT a row stamped with another tenant's id. +-- , true makes an unset GUC return NULL (-> no rows) instead of +-- RAISING, so an unconverted path fails closed and quiet +-- rather than 500-ing everywhere at once. + +ALTER TABLE access_request ENABLE ROW LEVEL SECURITY; +ALTER TABLE access_request FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS access_request_tenant_isolation ON access_request; +CREATE POLICY access_request_tenant_isolation ON access_request + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE action_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE action_item FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS action_item_tenant_isolation ON action_item; +CREATE POLICY action_item_tenant_isolation ON action_item + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE agent_avatars ENABLE ROW LEVEL SECURITY; +ALTER TABLE agent_avatars FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS agent_avatars_tenant_isolation ON agent_avatars; +CREATE POLICY agent_avatars_tenant_isolation ON agent_avatars + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE agent_blob ENABLE ROW LEVEL SECURITY; +ALTER TABLE agent_blob FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS agent_blob_tenant_isolation ON agent_blob; +CREATE POLICY agent_blob_tenant_isolation ON agent_blob + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE agent_file_history ENABLE ROW LEVEL SECURITY; +ALTER TABLE agent_file_history FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS agent_file_history_tenant_isolation ON agent_file_history; +CREATE POLICY agent_file_history_tenant_isolation ON agent_file_history + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE agent_run ENABLE ROW LEVEL SECURITY; +ALTER TABLE agent_run FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS agent_run_tenant_isolation ON agent_run; +CREATE POLICY agent_run_tenant_isolation ON agent_run + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE agent_skill_setting ENABLE ROW LEVEL SECURITY; +ALTER TABLE agent_skill_setting FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS agent_skill_setting_tenant_isolation ON agent_skill_setting; +CREATE POLICY agent_skill_setting_tenant_isolation ON agent_skill_setting + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE app_audit ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_audit FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS app_audit_tenant_isolation ON app_audit; +CREATE POLICY app_audit_tenant_isolation ON app_audit + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE app_data ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_data FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS app_data_tenant_isolation ON app_data; +CREATE POLICY app_data_tenant_isolation ON app_data + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE app_files ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_files FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS app_files_tenant_isolation ON app_files; +CREATE POLICY app_files_tenant_isolation ON app_files + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE app_grants ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_grants FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS app_grants_tenant_isolation ON app_grants; +CREATE POLICY app_grants_tenant_isolation ON app_grants + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE app_pins ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_pins FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS app_pins_tenant_isolation ON app_pins; +CREATE POLICY app_pins_tenant_isolation ON app_pins + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE app_tool_grants ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_tool_grants FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS app_tool_grants_tenant_isolation ON app_tool_grants; +CREATE POLICY app_tool_grants_tenant_isolation ON app_tool_grants + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE app_user ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_user FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS app_user_tenant_isolation ON app_user; +CREATE POLICY app_user_tenant_isolation ON app_user + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE app_versions ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_versions FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS app_versions_tenant_isolation ON app_versions; +CREATE POLICY app_versions_tenant_isolation ON app_versions + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE apps ENABLE ROW LEVEL SECURITY; +ALTER TABLE apps FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS apps_tenant_isolation ON apps; +CREATE POLICY apps_tenant_isolation ON apps + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE audit_event ENABLE ROW LEVEL SECURITY; +ALTER TABLE audit_event FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS audit_event_tenant_isolation ON audit_event; +CREATE POLICY audit_event_tenant_isolation ON audit_event + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE chat_message ENABLE ROW LEVEL SECURITY; +ALTER TABLE chat_message FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS chat_message_tenant_isolation ON chat_message; +CREATE POLICY chat_message_tenant_isolation ON chat_message + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE chat_session ENABLE ROW LEVEL SECURITY; +ALTER TABLE chat_session FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS chat_session_tenant_isolation ON chat_session; +CREATE POLICY chat_session_tenant_isolation ON chat_session + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE chat_session_agent ENABLE ROW LEVEL SECURITY; +ALTER TABLE chat_session_agent FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS chat_session_agent_tenant_isolation ON chat_session_agent; +CREATE POLICY chat_session_agent_tenant_isolation ON chat_session_agent + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE chat_session_participant ENABLE ROW LEVEL SECURITY; +ALTER TABLE chat_session_participant FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS chat_session_participant_tenant_isolation ON chat_session_participant; +CREATE POLICY chat_session_participant_tenant_isolation ON chat_session_participant + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE copilot_config ENABLE ROW LEVEL SECURITY; +ALTER TABLE copilot_config FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS copilot_config_tenant_isolation ON copilot_config; +CREATE POLICY copilot_config_tenant_isolation ON copilot_config + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE copilot_event ENABLE ROW LEVEL SECURITY; +ALTER TABLE copilot_event FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS copilot_event_tenant_isolation ON copilot_event; +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; +CREATE POLICY crm_deal_contacts_tenant_isolation ON crm_deal_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_statuses ENABLE ROW LEVEL SECURITY; +ALTER TABLE crm_deal_statuses FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS crm_deal_statuses_tenant_isolation ON crm_deal_statuses; +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; +CREATE POLICY crm_lead_statuses_tenant_isolation ON crm_lead_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_leads ENABLE ROW LEVEL SECURITY; +ALTER TABLE crm_leads FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS crm_leads_tenant_isolation ON crm_leads; +CREATE POLICY crm_leads_tenant_isolation ON crm_leads + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE crm_lost_reasons ENABLE ROW LEVEL SECURITY; +ALTER TABLE crm_lost_reasons FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS crm_lost_reasons_tenant_isolation ON crm_lost_reasons; +CREATE POLICY crm_lost_reasons_tenant_isolation ON crm_lost_reasons + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE crm_organizations ENABLE ROW LEVEL SECURITY; +ALTER TABLE crm_organizations FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS crm_organizations_tenant_isolation ON crm_organizations; +CREATE POLICY crm_organizations_tenant_isolation ON crm_organizations + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE crm_status_changes ENABLE ROW LEVEL SECURITY; +ALTER TABLE crm_status_changes FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS crm_status_changes_tenant_isolation ON crm_status_changes; +CREATE POLICY crm_status_changes_tenant_isolation ON crm_status_changes + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE crm_sync_cursors ENABLE ROW LEVEL SECURITY; +ALTER TABLE crm_sync_cursors FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS crm_sync_cursors_tenant_isolation ON crm_sync_cursors; +CREATE POLICY crm_sync_cursors_tenant_isolation ON crm_sync_cursors + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE crm_zoho_tombstones ENABLE ROW LEVEL SECURITY; +ALTER TABLE crm_zoho_tombstones FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS crm_zoho_tombstones_tenant_isolation ON crm_zoho_tombstones; +CREATE POLICY crm_zoho_tombstones_tenant_isolation ON crm_zoho_tombstones + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE custom_api_definitions ENABLE ROW LEVEL SECURITY; +ALTER TABLE custom_api_definitions FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS custom_api_definitions_tenant_isolation ON custom_api_definitions; +CREATE POLICY custom_api_definitions_tenant_isolation ON custom_api_definitions + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE customer ENABLE ROW LEVEL SECURITY; +ALTER TABLE customer FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS customer_tenant_isolation ON customer; +CREATE POLICY customer_tenant_isolation ON customer + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE deal ENABLE ROW LEVEL SECURITY; +ALTER TABLE deal FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS deal_tenant_isolation ON deal; +CREATE POLICY deal_tenant_isolation ON deal + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE dynamic_agents ENABLE ROW LEVEL SECURITY; +ALTER TABLE dynamic_agents FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS dynamic_agents_tenant_isolation ON dynamic_agents; +CREATE POLICY dynamic_agents_tenant_isolation ON dynamic_agents + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_accounts ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_accounts FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_accounts_tenant_isolation ON email_accounts; +CREATE POLICY email_accounts_tenant_isolation ON email_accounts + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_actions ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_actions FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_actions_tenant_isolation ON email_actions; +CREATE POLICY email_actions_tenant_isolation ON email_actions + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_ai_drafts ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_ai_drafts FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_ai_drafts_tenant_isolation ON email_ai_drafts; +CREATE POLICY email_ai_drafts_tenant_isolation ON email_ai_drafts + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_assistant_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_assistant_settings FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_assistant_settings_tenant_isolation ON email_assistant_settings; +CREATE POLICY email_assistant_settings_tenant_isolation ON email_assistant_settings + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_attachments ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_attachments FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_attachments_tenant_isolation ON email_attachments; +CREATE POLICY email_attachments_tenant_isolation ON email_attachments + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_cold_senders ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_cold_senders FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_cold_senders_tenant_isolation ON email_cold_senders; +CREATE POLICY email_cold_senders_tenant_isolation ON email_cold_senders + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_contacts ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_contacts FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_contacts_tenant_isolation ON email_contacts; +CREATE POLICY email_contacts_tenant_isolation ON email_contacts + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_embeddings ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_embeddings FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_embeddings_tenant_isolation ON email_embeddings; +CREATE POLICY email_embeddings_tenant_isolation ON email_embeddings + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_executed_rules ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_executed_rules FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_executed_rules_tenant_isolation ON email_executed_rules; +CREATE POLICY email_executed_rules_tenant_isolation ON email_executed_rules + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_folders ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_folders FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_folders_tenant_isolation ON email_folders; +CREATE POLICY email_folders_tenant_isolation ON email_folders + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_knowledge ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_knowledge FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_knowledge_tenant_isolation ON email_knowledge; +CREATE POLICY email_knowledge_tenant_isolation ON email_knowledge + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_learned_patterns ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_learned_patterns FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_learned_patterns_tenant_isolation ON email_learned_patterns; +CREATE POLICY email_learned_patterns_tenant_isolation ON email_learned_patterns + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_messages FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_messages_tenant_isolation ON email_messages; +CREATE POLICY email_messages_tenant_isolation ON email_messages + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_newsletters ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_newsletters FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_newsletters_tenant_isolation ON email_newsletters; +CREATE POLICY email_newsletters_tenant_isolation ON email_newsletters + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_rule_guidance ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_rule_guidance FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_rule_guidance_tenant_isolation ON email_rule_guidance; +CREATE POLICY email_rule_guidance_tenant_isolation ON email_rule_guidance + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_rule_patterns ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_rule_patterns FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_rule_patterns_tenant_isolation ON email_rule_patterns; +CREATE POLICY email_rule_patterns_tenant_isolation ON email_rule_patterns + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_rules ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_rules FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_rules_tenant_isolation ON email_rules; +CREATE POLICY email_rules_tenant_isolation ON email_rules + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_senders ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_senders FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_senders_tenant_isolation ON email_senders; +CREATE POLICY email_senders_tenant_isolation ON email_senders + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_sync_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_sync_log FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_sync_log_tenant_isolation ON email_sync_log; +CREATE POLICY email_sync_log_tenant_isolation ON email_sync_log + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_thread_status ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_thread_status FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_thread_status_tenant_isolation ON email_thread_status; +CREATE POLICY email_thread_status_tenant_isolation ON email_thread_status + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE email_voice_profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE email_voice_profiles FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS email_voice_profiles_tenant_isolation ON email_voice_profiles; +CREATE POLICY email_voice_profiles_tenant_isolation ON email_voice_profiles + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_attachments ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_attachments FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_attachments_tenant_isolation ON gtd_attachments; +CREATE POLICY gtd_attachments_tenant_isolation ON gtd_attachments + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_contexts ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_contexts FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_contexts_tenant_isolation ON gtd_contexts; +CREATE POLICY gtd_contexts_tenant_isolation ON gtd_contexts + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_day_state ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_day_state FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_day_state_tenant_isolation ON gtd_day_state; +CREATE POLICY gtd_day_state_tenant_isolation ON gtd_day_state + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_folders ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_folders FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_folders_tenant_isolation ON gtd_folders; +CREATE POLICY gtd_folders_tenant_isolation ON gtd_folders + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_horizons ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_horizons FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_horizons_tenant_isolation ON gtd_horizons; +CREATE POLICY gtd_horizons_tenant_isolation ON gtd_horizons + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_items ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_items FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_items_tenant_isolation ON gtd_items; +CREATE POLICY gtd_items_tenant_isolation ON gtd_items + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_people ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_people FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_people_tenant_isolation ON gtd_people; +CREATE POLICY gtd_people_tenant_isolation ON gtd_people + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_person_resumes ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_person_resumes FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_person_resumes_tenant_isolation ON gtd_person_resumes; +CREATE POLICY gtd_person_resumes_tenant_isolation ON gtd_person_resumes + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_projects ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_projects FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_projects_tenant_isolation ON gtd_projects; +CREATE POLICY gtd_projects_tenant_isolation ON gtd_projects + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_reviews ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_reviews FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_reviews_tenant_isolation ON gtd_reviews; +CREATE POLICY gtd_reviews_tenant_isolation ON gtd_reviews + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_rollover_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_rollover_log FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_rollover_log_tenant_isolation ON gtd_rollover_log; +CREATE POLICY gtd_rollover_log_tenant_isolation ON gtd_rollover_log + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_settings FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_settings_tenant_isolation ON gtd_settings; +CREATE POLICY gtd_settings_tenant_isolation ON gtd_settings + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_spaces ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_spaces FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_spaces_tenant_isolation ON gtd_spaces; +CREATE POLICY gtd_spaces_tenant_isolation ON gtd_spaces + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE gtd_waiting ENABLE ROW LEVEL SECURITY; +ALTER TABLE gtd_waiting FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS gtd_waiting_tenant_isolation ON gtd_waiting; +CREATE POLICY gtd_waiting_tenant_isolation ON gtd_waiting + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE if ENABLE ROW LEVEL SECURITY; +ALTER TABLE if FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS if_tenant_isolation ON if; +CREATE POLICY if_tenant_isolation ON if + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE live_session ENABLE ROW LEVEL SECURITY; +ALTER TABLE live_session FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS live_session_tenant_isolation ON live_session; +CREATE POLICY live_session_tenant_isolation ON live_session + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE meeting ENABLE ROW LEVEL SECURITY; +ALTER TABLE meeting FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS meeting_tenant_isolation ON meeting; +CREATE POLICY meeting_tenant_isolation ON meeting + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE meeting_bot ENABLE ROW LEVEL SECURITY; +ALTER TABLE meeting_bot FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS meeting_bot_tenant_isolation ON meeting_bot; +CREATE POLICY meeting_bot_tenant_isolation ON meeting_bot + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE meeting_note ENABLE ROW LEVEL SECURITY; +ALTER TABLE meeting_note FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS meeting_note_tenant_isolation ON meeting_note; +CREATE POLICY meeting_note_tenant_isolation ON meeting_note + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE meeting_recording ENABLE ROW LEVEL SECURITY; +ALTER TABLE meeting_recording FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS meeting_recording_tenant_isolation ON meeting_recording; +CREATE POLICY meeting_recording_tenant_isolation ON meeting_recording + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE message ENABLE ROW LEVEL SECURITY; +ALTER TABLE message FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS message_tenant_isolation ON message; +CREATE POLICY message_tenant_isolation ON message + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE notes_glossary ENABLE ROW LEVEL SECURITY; +ALTER TABLE notes_glossary FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS notes_glossary_tenant_isolation ON notes_glossary; +CREATE POLICY notes_glossary_tenant_isolation ON notes_glossary + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE org_group_member ENABLE ROW LEVEL SECURITY; +ALTER TABLE org_group_member FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS org_group_member_tenant_isolation ON org_group_member; +CREATE POLICY org_group_member_tenant_isolation ON org_group_member + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE org_role_permission ENABLE ROW LEVEL SECURITY; +ALTER TABLE org_role_permission FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS org_role_permission_tenant_isolation ON org_role_permission; +CREATE POLICY org_role_permission_tenant_isolation ON org_role_permission + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE org_settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE org_settings FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS org_settings_tenant_isolation ON org_settings; +CREATE POLICY org_settings_tenant_isolation ON org_settings + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pending_actions ENABLE ROW LEVEL SECURITY; +ALTER TABLE pending_actions FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pending_actions_tenant_isolation ON pending_actions; +CREATE POLICY pending_actions_tenant_isolation ON pending_actions + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pending_commit ENABLE ROW LEVEL SECURITY; +ALTER TABLE pending_commit FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pending_commit_tenant_isolation ON pending_commit; +CREATE POLICY pending_commit_tenant_isolation ON pending_commit + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE person ENABLE ROW LEVEL SECURITY; +ALTER TABLE person FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS person_tenant_isolation ON person; +CREATE POLICY person_tenant_isolation ON person + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE plugins ENABLE ROW LEVEL SECURITY; +ALTER TABLE plugins FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS plugins_tenant_isolation ON plugins; +CREATE POLICY plugins_tenant_isolation ON plugins + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_activities ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_activities FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_activities_tenant_isolation ON pm_activities; +CREATE POLICY pm_activities_tenant_isolation ON pm_activities + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_custom_fields ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_custom_fields FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_custom_fields_tenant_isolation ON pm_custom_fields; +CREATE POLICY pm_custom_fields_tenant_isolation ON pm_custom_fields + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_notifications ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_notifications FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_notifications_tenant_isolation ON pm_notifications; +CREATE POLICY pm_notifications_tenant_isolation ON pm_notifications + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_project_grants ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_project_grants FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_project_grants_tenant_isolation ON pm_project_grants; +CREATE POLICY pm_project_grants_tenant_isolation ON pm_project_grants + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_projects ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_projects FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_projects_tenant_isolation ON pm_projects; +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_tags ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_tags FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_tags_tenant_isolation ON pm_tags; +CREATE POLICY pm_tags_tenant_isolation ON pm_tags + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_task_assignees ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_task_assignees FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_task_assignees_tenant_isolation ON pm_task_assignees; +CREATE POLICY pm_task_assignees_tenant_isolation ON pm_task_assignees + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_task_attachments ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_task_attachments FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_task_attachments_tenant_isolation ON pm_task_attachments; +CREATE POLICY pm_task_attachments_tenant_isolation ON pm_task_attachments + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_task_counters ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_task_counters FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_task_counters_tenant_isolation ON pm_task_counters; +CREATE POLICY pm_task_counters_tenant_isolation ON pm_task_counters + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_task_links ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_task_links FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_task_links_tenant_isolation ON pm_task_links; +CREATE POLICY pm_task_links_tenant_isolation ON pm_task_links + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_task_personal ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_task_personal FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_task_personal_tenant_isolation ON pm_task_personal; +CREATE POLICY pm_task_personal_tenant_isolation ON pm_task_personal + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_task_statuses ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_task_statuses FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_task_statuses_tenant_isolation ON pm_task_statuses; +CREATE POLICY pm_task_statuses_tenant_isolation ON pm_task_statuses + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_task_types ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_task_types FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_task_types_tenant_isolation ON pm_task_types; +CREATE POLICY pm_task_types_tenant_isolation ON pm_task_types + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_tasks ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_tasks FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_tasks_tenant_isolation ON pm_tasks; +CREATE POLICY pm_tasks_tenant_isolation ON pm_tasks + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_view_task_positions ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_view_task_positions FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_view_task_positions_tenant_isolation ON pm_view_task_positions; +CREATE POLICY pm_view_task_positions_tenant_isolation ON pm_view_task_positions + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE pm_views ENABLE ROW LEVEL SECURITY; +ALTER TABLE pm_views FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS pm_views_tenant_isolation ON pm_views; +CREATE POLICY pm_views_tenant_isolation ON pm_views + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE project ENABLE ROW LEVEL SECURITY; +ALTER TABLE project FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS project_tenant_isolation ON project; +CREATE POLICY project_tenant_isolation ON project + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE summary_run ENABLE ROW LEVEL SECURITY; +ALTER TABLE summary_run FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS summary_run_tenant_isolation ON summary_run; +CREATE POLICY summary_run_tenant_isolation ON summary_run + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE task ENABLE ROW LEVEL SECURITY; +ALTER TABLE task FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS task_tenant_isolation ON task; +CREATE POLICY task_tenant_isolation ON task + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE task_accounts ENABLE ROW LEVEL SECURITY; +ALTER TABLE task_accounts FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS task_accounts_tenant_isolation ON task_accounts; +CREATE POLICY task_accounts_tenant_isolation ON task_accounts + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE transcript_segment ENABLE ROW LEVEL SECURITY; +ALTER TABLE transcript_segment FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS transcript_segment_tenant_isolation ON transcript_segment; +CREATE POLICY transcript_segment_tenant_isolation ON transcript_segment + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE user_permission_override ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_permission_override FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS user_permission_override_tenant_isolation ON user_permission_override; +CREATE POLICY user_permission_override_tenant_isolation ON user_permission_override + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE user_role ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_role FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS user_role_tenant_isolation ON user_role; +CREATE POLICY user_role_tenant_isolation ON user_role + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_accounts ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_accounts FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_accounts_tenant_isolation ON wa_accounts; +CREATE POLICY wa_accounts_tenant_isolation ON wa_accounts + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_ai_drafts ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_ai_drafts FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_ai_drafts_tenant_isolation ON wa_ai_drafts; +CREATE POLICY wa_ai_drafts_tenant_isolation ON wa_ai_drafts + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_categories ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_categories FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_categories_tenant_isolation ON wa_categories; +CREATE POLICY wa_categories_tenant_isolation ON wa_categories + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_chat_avatars ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_chat_avatars FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_chat_avatars_tenant_isolation ON wa_chat_avatars; +CREATE POLICY wa_chat_avatars_tenant_isolation ON wa_chat_avatars + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_chat_labels ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_chat_labels FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_chat_labels_tenant_isolation ON wa_chat_labels; +CREATE POLICY wa_chat_labels_tenant_isolation ON wa_chat_labels + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_chat_status ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_chat_status FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_chat_status_tenant_isolation ON wa_chat_status; +CREATE POLICY wa_chat_status_tenant_isolation ON wa_chat_status + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_chats ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_chats FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_chats_tenant_isolation ON wa_chats; +CREATE POLICY wa_chats_tenant_isolation ON wa_chats + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_commitments ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_commitments FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_commitments_tenant_isolation ON wa_commitments; +CREATE POLICY wa_commitments_tenant_isolation ON wa_commitments + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_contacts ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_contacts FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_contacts_tenant_isolation ON wa_contacts; +CREATE POLICY wa_contacts_tenant_isolation ON wa_contacts + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_group_summaries ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_group_summaries FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_group_summaries_tenant_isolation ON wa_group_summaries; +CREATE POLICY wa_group_summaries_tenant_isolation ON wa_group_summaries + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_labels ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_labels FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_labels_tenant_isolation ON wa_labels; +CREATE POLICY wa_labels_tenant_isolation ON wa_labels + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_media ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_media FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_media_tenant_isolation ON wa_media; +CREATE POLICY wa_media_tenant_isolation ON wa_media + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_message_embeddings ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_message_embeddings FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_message_embeddings_tenant_isolation ON wa_message_embeddings; +CREATE POLICY wa_message_embeddings_tenant_isolation ON wa_message_embeddings + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_messages FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_messages_tenant_isolation ON wa_messages; +CREATE POLICY wa_messages_tenant_isolation ON wa_messages + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_saved_replies ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_saved_replies FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_saved_replies_tenant_isolation ON wa_saved_replies; +CREATE POLICY wa_saved_replies_tenant_isolation ON wa_saved_replies + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_sync_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_sync_log FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_sync_log_tenant_isolation ON wa_sync_log; +CREATE POLICY wa_sync_log_tenant_isolation ON wa_sync_log + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE wa_templates ENABLE ROW LEVEL SECURITY; +ALTER TABLE wa_templates FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS wa_templates_tenant_isolation ON wa_templates; +CREATE POLICY wa_templates_tenant_isolation ON wa_templates + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE workflow_modules ENABLE ROW LEVEL SECURITY; +ALTER TABLE workflow_modules FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS workflow_modules_tenant_isolation ON workflow_modules; +CREATE POLICY workflow_modules_tenant_isolation ON workflow_modules + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE workflow_run_pauses ENABLE ROW LEVEL SECURITY; +ALTER TABLE workflow_run_pauses FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS workflow_run_pauses_tenant_isolation ON workflow_run_pauses; +CREATE POLICY workflow_run_pauses_tenant_isolation ON workflow_run_pauses + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE workflow_runs ENABLE ROW LEVEL SECURITY; +ALTER TABLE workflow_runs FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS workflow_runs_tenant_isolation ON workflow_runs; +CREATE POLICY workflow_runs_tenant_isolation ON workflow_runs + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE workflow_triggers ENABLE ROW LEVEL SECURITY; +ALTER TABLE workflow_triggers FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS workflow_triggers_tenant_isolation ON workflow_triggers; +CREATE POLICY workflow_triggers_tenant_isolation ON workflow_triggers + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE workflow_versions ENABLE ROW LEVEL SECURITY; +ALTER TABLE workflow_versions FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS workflow_versions_tenant_isolation ON workflow_versions; +CREATE POLICY workflow_versions_tenant_isolation ON workflow_versions + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); + +ALTER TABLE workflows ENABLE ROW LEVEL SECURITY; +ALTER TABLE workflows FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS workflows_tenant_isolation ON workflows; +CREATE POLICY workflows_tenant_isolation ON workflows + USING (organization_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid); diff --git a/packages/acb_auth/acb_auth/access.py b/packages/acb_auth/acb_auth/access.py index 832fd609a..4da0b503c 100644 --- a/packages/acb_auth/acb_auth/access.py +++ b/packages/acb_auth/acb_auth/access.py @@ -389,16 +389,41 @@ async def resolve_identity(email: str | None) -> tuple[str | None, str | None]: SELECT subject FROM chat_session_participant WHERE session_id = :sid """ +#: Both expansions are scoped to the actor's organization, joined in as `actor`. +#: +#: `org_group.slug` is unique only *within* an organization (`UNIQUE +#: (organization_id, slug)`, `138_groups_and_session_participants.sql:49`), so +#: the slug-only join matched every tenant's group of that name at once; and the +#: `org` subject, with no filter at all, expanded to every active user on the +#: box (`saas_multitenancy.md` §6.4, §6.5 — under D15 the tenant boundary is a +#: row, so both leak for real). This is the most consequential place to get it +#: wrong: `resolve_session_access` folds an *intersection*, and admitting a +#: member who was never in the room widens the fold rather than narrowing it. +#: +#: The organization is derived from the acting user's own `app_user` row, not +#: from a literal org slug, so it cannot go stale (`tenancy_and_visibility.md` +#: §2 done-when 1). An actor with no `app_user` row — or a member row with a +#: NULL `organization_id`, which migration 130 backfilled but nothing enforces +#: — matches nothing and drops out of the expansion. That fails *closed*: the +#: run keeps the actor's own access instead of borrowing someone else's. _GROUP_MEMBER_SQL = """ SELECT au.email FROM org_group g JOIN org_group_member m ON m.group_id = g.id JOIN app_user au ON au.id = m.user_id -WHERE g.slug = :slug AND au.status = 'active' +JOIN app_user actor ON lower(actor.email) = :actor_email +WHERE g.slug = :slug + AND au.status = 'active' + AND g.organization_id = actor.organization_id + AND au.organization_id = actor.organization_id """ _ORG_MEMBER_SQL = """ -SELECT email FROM app_user WHERE status = 'active' +SELECT au.email +FROM app_user au +JOIN app_user actor ON lower(actor.email) = :actor_email +WHERE au.status = 'active' + AND au.organization_id = actor.organization_id """ @@ -419,7 +444,8 @@ async def resolve_session_access( * Participant subjects are expanded at read time — an email is itself, ``group:`` becomes the group's active members, ``org`` becomes every active member (an org-visible room is readable by all of them, - so all of them cap it). + so all of them cap it). Both expansions stay inside the *actor's* + organization; see ``_GROUP_MEMBER_SQL``. * The actor is always included, so a solo session — or any session recorded before migration 138 — resolves to exactly the actor's own access, byte-identically to today. Everything here activates only when @@ -460,12 +486,17 @@ async def resolve_session_access( if not s: continue if s == "org": - rows = (await session.execute(text(_ORG_MEMBER_SQL))).fetchall() + rows = ( + await session.execute( + text(_ORG_MEMBER_SQL), {"actor_email": actor}, + ) + ).fetchall() emails.update(r[0].lower() for r in rows if r[0]) elif s.startswith("group:"): rows = ( await session.execute( - text(_GROUP_MEMBER_SQL), {"slug": s[len("group:"):]}, + text(_GROUP_MEMBER_SQL), + {"slug": s[len("group:"):], "actor_email": actor}, ) ).fetchall() emails.update(r[0].lower() for r in rows if r[0]) @@ -498,9 +529,19 @@ async def resolve_session_access( # ── Ownership bootstrap (the way back in) ─────────────────────────────────── +#: The organization this deployment bootstraps an owner into. +#: +#: Still a literal — provisioning a *customer* organization's first owner is +#: onboarding's job, not an env var's, and EXECUTIVE_EMAILS names the operator +#: of this box. Named once and bound into both queries below so the guard and +#: the insert can never disagree about which organization they mean; the guard +#: used to answer a different question from the insert, which is the whole bug +#: (`tenancy_and_visibility.md` §1.1 site 9). +_BOOTSTRAP_ORG_SLUG = "default" + _BOOTSTRAP_OWNER_SQL = """ WITH org AS ( - SELECT id FROM organization WHERE slug = 'default' + SELECT id FROM organization WHERE slug = :org_slug ), member AS ( INSERT INTO app_user (email, display_name, role, status, @@ -519,9 +560,19 @@ async def resolve_session_access( ON CONFLICT DO NOTHING """ +#: Does *this* organization have an owner — not "does an owner exist anywhere". +#: +#: `org_role` is per-organization, so an unscoped `r.slug = 'owner'` answered a +#: question nobody asked: once any one tenant had an owner, the guard below went +#: permanently false and `ensure_owner_bootstrap()` became a no-op for an +#: organization that had none. That is a **lockout, not a leak** — no owner +#: means no inviter, and the only way in is hand-run SQL. RLS does not fix it, +#: because the defect is a missing WHERE on a startup path, not a visible row +#: (`saas_multitenancy.md` §6.4; `tenancy_and_visibility.md` §1.1 site 9). _HAS_OWNER_SQL = """ SELECT 1 FROM user_role ur JOIN org_role r ON r.id = ur.role_id AND r.slug = 'owner' +JOIN organization o ON o.id = r.organization_id AND o.slug = :org_slug LIMIT 1 """ @@ -540,10 +591,13 @@ async def ensure_owner_bootstrap() -> str | None: gateway startup, the first place both the database AND the environment are readable. - Deliberately narrow: it runs only when NO member holds ``owner`` — one - real owner anywhere (however provisioned) makes this a no-op forever, so - a stale or placeholder EXECUTIVE_EMAILS can never overwrite real - membership. Returns the provisioned email, or ``None`` when it did + Deliberately narrow: it runs only when nobody holds ``owner`` **in the + organization it would provision into** (``_BOOTSTRAP_ORG_SLUG``) — one + real owner there (however provisioned) makes this a no-op forever, so a + stale or placeholder EXECUTIVE_EMAILS can never overwrite real + membership. The scoping is the 2026-08-08 fix: unscoped, another tenant's + owner satisfied the guard and re-locked this deployment out of its own + bootstrap. Returns the provisioned email, or ``None`` when it did nothing. Never raises: an ownerless deployment with a broken bootstrap should still boot and serve /health, not crash-loop. """ @@ -556,7 +610,10 @@ async def ensure_owner_bootstrap() -> str | None: factory = _get_session_factory() async with factory() as session: - if (await session.execute(text(_HAS_OWNER_SQL))).first() is not None: + has_owner = await session.execute( + text(_HAS_OWNER_SQL), {"org_slug": _BOOTSTRAP_ORG_SLUG}, + ) + if has_owner.first() is not None: return None if candidate is None: _log.warning( @@ -568,7 +625,10 @@ async def ensure_owner_bootstrap() -> str | None: ), ) return None - await session.execute(text(_BOOTSTRAP_OWNER_SQL), {"email": candidate}) + await session.execute( + text(_BOOTSTRAP_OWNER_SQL), + {"email": candidate, "org_slug": _BOOTSTRAP_ORG_SLUG}, + ) await session.commit() invalidate(candidate) _log.warning( diff --git a/packages/acb_common/acb_common/db.py b/packages/acb_common/acb_common/db.py index cb1a25bc1..76e3ba09b 100644 --- a/packages/acb_common/acb_common/db.py +++ b/packages/acb_common/acb_common/db.py @@ -34,6 +34,9 @@ from __future__ import annotations import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from contextvars import ContextVar, Token from typing import Any from acb_common.settings import get_settings @@ -132,5 +135,110 @@ def get_session_factory() -> Any: async def get_db() -> Any: - """Return a new async session from the shared, pooled engine.""" + """Return a new async session from the shared, pooled engine. + + ⚠️ **Not tenant-bound.** Under D15 (`saas_multitenancy.md` §1) tenant + isolation is a Postgres RLS policy keyed on the ``app.tenant_id`` setting, + and this session sets nothing — so once phase-4 policies apply it reads + **zero rows**, by design (§0.1's fail-closed property). Reach for + :func:`tenant_session` instead. This entry point stays for the ~200 existing + call sites, which MT-1c converts; it is not a second sanctioned way in. + """ return get_session_factory()() + + +# ── Tenant binding (MT-1c) ────────────────────────────────────────────────── +# +# `saas_multitenancy.md` §0.1 / §1.3. RLS enforces isolation server-side, but +# only against a session that has told the server which tenant it is acting for. +# This is that seam — and it is the ONE place the binding happens, which is what +# makes the pooled decision defensible across ten connection paths nobody can +# hold in their head (§0.1's inventory was itself wrong twice). + +_TENANT: ContextVar[str | None] = ContextVar("acb_tenant", default=None) + + +class TenantUnbound(RuntimeError): + """No tenant in context and none supplied. **Never defaulted.** + + A session that cannot say which tenant it acts for must not open. The + alternative — picking "the usual one" — is how a background job writes one + customer's data into another customer's account. + """ + + +def bind_tenant(organization_id: str) -> Token[str | None]: + """Bind *organization_id* for this async context. Returns a reset token. + + Request handlers bind from the authenticated session; jobs bind from their + own record (MT-1d). **Never from a header, query parameter or body field** — + `user_management_contract.md` **R11**. + """ + return _TENANT.set(str(organization_id)) + + +def release_tenant(token: Token[str | None] | None) -> None: + """Undo :func:`bind_tenant`. Never raises.""" + if token is None: + return + try: + _TENANT.reset(token) + except (ValueError, RuntimeError): + _TENANT.set(None) + + +def current_tenant() -> str | None: + """The tenant bound to this context, if any.""" + return _TENANT.get() + + +@asynccontextmanager +async def tenant_session(organization_id: str | None = None) -> AsyncIterator[Any]: + """A session bound to a tenant for the life of one transaction. + + ⚠️ **``SET LOCAL``, never ``SET`` — this is the highest-consequence line in + the whole tenancy migration.** The engine pools connections + (``pool_size`` + ``max_overflow`` above). A session-scoped ``SET`` survives + the connection's return to the pool, so the *next* borrower — a different + request, a different customer — inherits it and reads their data. ``SET + LOCAL`` is transaction-scoped and resets on commit or rollback. + + ⚠️ **And it needs a real transaction.** ``SET LOCAL`` outside one is a silent + no-op: Postgres warns, the policy then sees an unset GUC, and every query + returns nothing. That presents as *"the feature is broken"*, not as + *"tenancy is broken"*, which is why ``begin()`` is explicit here rather than + left to SQLAlchemy's autobegin. + + Usage:: + + async with tenant_session() as db: # tenant from context + rows = await db.execute(text("SELECT ...")) + + async with tenant_session(org_id) as db: # explicit, e.g. a job + ... + + Raises: + TenantUnbound: nothing bound and nothing supplied. + """ + from sqlalchemy import text + + tenant = organization_id or _TENANT.get() + if not tenant: + raise TenantUnbound( + "no tenant bound — a caller outside a request or job must pass one " + "explicitly (saas_multitenancy.md §0.1 / MT-1c)" + ) + + session = get_session_factory()() + try: + await session.begin() + await session.execute( + text("SET LOCAL app.tenant_id = :tenant"), {"tenant": str(tenant)} + ) + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/packages/acb_common/acb_common/placement.py b/packages/acb_common/acb_common/placement.py new file mode 100644 index 000000000..35aecb524 --- /dev/null +++ b/packages/acb_common/acb_common/placement.py @@ -0,0 +1,127 @@ +"""Tenant placement — which data plane serves an organization (MT-1a). + +Spec: ``ai-company-brain/specs/saas_multitenancy.md`` §1.5 · D15 · board WS-29. + +Under D15 the tenant boundary is a **row** (`organization_id` + Postgres RLS) and +the *deployment* is demoted to a **placement**: a region and a tier, not an +isolation boundary. Three unrelated customer demands all resolve to this one +mechanism — the dedicated-data tier a compliance-sensitive buyer asks for +(§1.5), the "is my data in the same database as my competitor's" procurement +question (§1.8a), and genuine version pinning (§1.4b). + +**On day one every tenant resolves to the same target.** That is not a reason to +skip the indirection — it *is* the indirection's value. Moving a customer to +their own database becomes a data move plus a row update, instead of an +architecture change made under pressure while that customer waits. §1.6 puts it +plainly: *a tenancy model you cannot reverse is the actual risk.* + +⚠️ **This module resolves placement, not identity.** It does not decide which +tenant a request belongs to — that comes from the authenticated session or a +tenant-scoped API key and from nowhere else (`user_management_contract.md` +**R11**). Passing a tenant id in here that came from a header would launder +exactly the input this platform must never trust. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from acb_common import get_logger + +_log = get_logger("placement") + +#: The tiers, in ascending order of isolation and price (§1.5). +TIERS: tuple[str, ...] = ("pool", "bridge", "silo") + +_PLACEMENT_SQL = """ +SELECT organization_id::text AS organization_id, tier, target, region + FROM tenant_placement + WHERE organization_id = :org_id +""" + +#: Resolution for a deployment that has not been split yet. Mirrors +#: ``key_store._resolve_org`` deliberately: "the sole organization" resolves +#: only while there IS exactly one, so an untenanted caller stops resolving the +#: moment a second tenant exists rather than silently picking the operator's. +_SOLE_PLACEMENT_SQL = """ +SELECT p.organization_id::text AS organization_id, p.tier, p.target, p.region + FROM tenant_placement p + WHERE (SELECT count(*) FROM organization) = 1 +""" + + +@dataclass(frozen=True) +class Placement: + """Where one tenant's data lives.""" + + organization_id: str + tier: str + target: str + region: str + + @property + def is_pooled(self) -> bool: + return self.tier == "pool" + + @property + def is_dedicated(self) -> bool: + """Own database or own stack — the priced tiers (§1.5).""" + return self.tier in ("bridge", "silo") + + +class PlacementUnresolved(RuntimeError): + """No placement could be resolved. **Always fatal, never defaulted.** + + A caller that cannot establish which data plane serves a tenant must not + guess: guessing means reading or writing another customer's data. Every + failure here is a refusal. + """ + + +async def resolve_placement(organization_id: str | None = None) -> Placement: + """Resolve the data plane serving *organization_id*. + + ``None`` resolves to the sole organization — which exists only while there + is one. Once a second tenant is created, an untenanted call **raises** + rather than falling back to the operator's org. That failure is the design: + it surfaces at exactly the moment a real tenant must be threaded through, + instead of quietly serving the wrong customer's data for however long it + takes someone to notice. + + Raises: + PlacementUnresolved: no row, more than one organization with no id + given, or the catalog is unreachable. + """ + from sqlalchemy import text + + from acb_common.db import get_db + + sql = _PLACEMENT_SQL if organization_id else _SOLE_PLACEMENT_SQL + params: dict[str, Any] = {"org_id": organization_id} if organization_id else {} + + try: + session = await get_db() + try: + row = (await session.execute(text(sql), params)).mappings().first() + finally: + await session.close() + except Exception as exc: + _log.warning("placement.lookup_failed", error=str(exc)[:200]) + raise PlacementUnresolved( + "the tenant catalog is unreachable — refusing to guess a placement" + ) from exc + + if row is None: + raise PlacementUnresolved( + f"no placement for organization_id={organization_id!r}" + if organization_id + else "no sole organization — pass an explicit organization_id " + "(saas_multitenancy.md §1.5)" + ) + + return Placement( + organization_id=row["organization_id"], + tier=row["tier"], + target=row["target"], + region=row["region"], + ) diff --git a/packages/acb_common/acb_common/tenant_redis.py b/packages/acb_common/acb_common/tenant_redis.py new file mode 100644 index 000000000..0cb8bc942 --- /dev/null +++ b/packages/acb_common/acb_common/tenant_redis.py @@ -0,0 +1,701 @@ +"""MT-1e — the tenant-prefixed Redis client. A key that carries no tenant cannot be built. + +``saas_multitenancy.md`` §1.9 opens with the sentence this module exists for: + + "Postgres RLS protects Postgres. These do not run on Postgres." + +Redis is the first row of that table. Every ``cc:*`` key in the tree today is +**deployment-global**: ``cc:activity`` is one stream for everyone, +``cc:room:{thread_id}`` is keyed by a thread id that is unique but not tenanted, +``cc:cost:{date}`` folds every customer's spend into one hash. Under one tenant +that is fine. Under two it is a cross-customer read with no server-side check in +front of it — Redis has no row-level policy to fall back on, so whatever the key +string says *is* the isolation. + +§0.9.4 states the required shape, and states **why it is a client and not a +convention**: + + "Redis stays, but tenant prefixing is enforced by a wrapper client, not by + convention. A convention is a thing people forget; a client that cannot + construct an unprefixed key is not." + +So the design goal here is not "make prefixing easy". It is: **make the wrong +key inexpressible.** Three mechanics get that: + +1. :class:`TenantKey` is the only thing the client's commands accept. A ``str`` + is rejected with ``TypeError`` — there is no method on this client that + takes a key you typed yourself. +2. :class:`TenantKey` cannot be built without a bound tenant. Its validation + runs in ``__post_init__``, so constructing one directly is not a way around + :func:`key` — it is the same check. +3. The tenant comes from a ``ContextVar`` and **fails closed**: unbound raises + :class:`TenantNotBound`. It never falls back to a global key, because a + silent global-key fallback is exactly the bug this ticket exists to make + impossible (compare §1.9's background-jobs row: "a job that forgets doesn't + leak one row; it leaks unbounded"). + +The ContextVar mirrors ``acb_skills/integrations.py``'s ``bind_run_credentials`` +/ ``_RUN_CREDENTIALS`` (MT-0a), deliberately and for the same reason it was +chosen there: a ContextVar is per-asyncio-task and is copied into tasks created +from the binding context, so two overlapping runs each see only their own value +with no shared window at all. Anything process-global — a module attribute, an +env var — is a leak the moment two tenants run concurrently in one process, and +this process runs model-generated tool calls over ingested email. + +Key shape (§1.9: *"Prefix every key ``cc::…``"*):: + + cc::[:...] + +Consumer groups (§1.9: *"separate consumer groups per tenant on the Streams +bus"*) get the same treatment: :class:`ConsumerGroup` is built by :func:`group` +and carries the tenant, and every group command on this client demands one. +A per-tenant stream key already separates the data; a per-tenant group name is +what stops one tenant's consumer from ACKing entries out of another's PEL if a +stream key is ever shared or replayed. + +Scope of this ticket — read before extending +-------------------------------------------- +This module ships the client, its tests and its ratchet. It deliberately +**converts no existing call site**: ~20 of them across four services, each with +its own liveness/TTL semantics, is a separate and much riskier change and must +not ride along on the change that introduces the mechanism. + +The ratchet that enforces adoption lives in ``tests/unit/test_tenant_redis.py``: +it fails the build on any *new* direct ``redis`` client, and carries today's +sites in an allow-list where each entry names the follow-up conversion. That +allow-list is the migration checklist — the follow-up ticket is done when it is +empty. Migration path, per call site: + +1. Bind the tenant at the entry point that already knows it (request handler, + job record, run start) with :func:`organization_scope` or + :func:`bind_organization`. **Do not** derive it inside the Redis call — §1.5 + binds the tenant from the authenticated session, never from a header or a + body field. +2. Replace the module's ``_get_client()`` with :func:`get_tenant_redis`. +3. Replace each ``_foo_key(x)`` helper's body with ``key("foo", x)``. The + prefix constant (``FOO_PREFIX = "cc:foo"``) becomes the namespace ``"foo"`` + and the ``cc:`` root is no longer written by hand anywhere. +4. Delete the allow-list entry in ``test_tenant_redis.py``. The stale-entry + test proves the list shrinks rather than silently becoming permission. + +Deployed keys written before a conversion are orphaned by it, by design: every +key here is a cache, a presence flag or a bounded live stream (the durable +record is Postgres), so the conversion is a cache-cold event, not a data +migration. Do not write a dual-read shim — a reader that falls back to the +unprefixed key is a reader that crosses the tenant boundary. + +Spec: ``saas_multitenancy.md`` §0.9.4, §1.9 · MT-1e · D15. +""" + +from __future__ import annotations + +import re +from collections.abc import AsyncIterator, Mapping, Sequence +from contextlib import contextmanager +from contextvars import ContextVar, Token +from dataclasses import dataclass, field +from typing import Any + +import redis.asyncio as aioredis + +from acb_common.settings import get_settings + +__all__ = [ + "KEY_ROOT", + "ConsumerGroup", + "ScanPattern", + "TenantKey", + "TenantMismatch", + "TenantNotBound", + "TenantRedis", + "bind_organization", + "current_organization", + "get_tenant_redis", + "group", + "key", + "match", + "organization_bound", + "organization_scope", + "release_organization", +] + +#: The one place the ``cc:`` root is written. Nothing else in the codebase +#: should ever type it again — that is the point of the module. +KEY_ROOT = "cc" + + +# ── Errors ─────────────────────────────────────────────────────────────────── + +class TenantNotBound(RuntimeError): + """No tenant is bound on this context, so no key can be built. + + Raised rather than defaulting to a global key. §1.9's fail-closed rule: the + caller that forgot to bind must break loudly here, in its own test run, + rather than quietly writing into a key every other tenant also reads. + """ + + +class TenantMismatch(RuntimeError): + """A key built for one tenant was used while another was bound. + + The realistic path to this is a key captured in a closure or a dict that + outlives its binding — e.g. a key built during a request and used later on a + background task that rebound to another tenant. Caught rather than executed. + """ + + +# ── The bound tenant (ContextVar; mirrors acb_skills.integrations, MT-0a) ───── + +#: ``None`` default rather than a sentinel org id — there is no such thing as a +#: default tenant, and a mutable/plausible default is how the fallback this +#: module forbids gets reintroduced. Readers go through +#: :func:`current_organization`, which turns the ``None`` into a raise. +_ORGANIZATION_ID: ContextVar[str | None] = ContextVar( + "acb_organization_id", default=None, +) + +#: D15: the tenant is ``organization_id``. Constrained to what is safe in a +#: Redis key *and* unambiguous inside one: no ``:`` (it would forge a second +#: prefix segment) and no glob metacharacter (it would widen a SCAN pattern +#: across tenants). UUIDs and slugs both pass. +_ORG_ID_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\Z") + +#: Namespaces are the vocabulary the codebase already uses — ``activity``, +#: ``room``, ``presence``, ``stream``, ``active``, ``runactor``, ``ctrl-ack``, … +#: A namespace may not contain ``:``; nesting is expressed with parts, so that +#: ``cc::activity:live:`` is ``key("activity", "live", run_id)`` and +#: the segment count is a property of the call, not of a string someone typed. +_NAMESPACE_RE = re.compile(r"\A[a-z0-9][a-z0-9_.-]{0,31}\Z") + + +def bind_organization(organization_id: str) -> Token[str | None]: + """Bind *organization_id* as this context's tenant. Returns a reset token. + + The caller **must** hand the token to :func:`release_organization` at + teardown, or use :func:`organization_scope`, which does it for you. + + Bind at the boundary that already established the tenant — the authenticated + session (§1.5) or a job record's explicit ``organization_id`` (§1.9). Never + from a header, query parameter or body field: MT-1f makes ignoring those a + build-failing test, and a Redis call site is not the place to relitigate it. + """ + org = _validated_org(organization_id) + return _ORGANIZATION_ID.set(org) + + +def release_organization(token: Token[str | None] | None) -> None: + """Undo :func:`bind_organization`. Never raises. + + ``ContextVar.reset`` rejects a token created in a *different* Context, which + a teardown that hopped tasks would hit. Falling back to an explicit unbind + keeps the failure closed — the tenant is gone either way — rather than + leaving the previous tenant bound because the reset raised. Same reasoning, + same shape as ``acb_skills.integrations.release_run_credentials``. + """ + if token is None: + return + try: + _ORGANIZATION_ID.reset(token) + except (ValueError, RuntimeError): + _ORGANIZATION_ID.set(None) + + +@contextmanager +def organization_scope(organization_id: str): + """``with organization_scope(org): …`` — bind for a block, always release.""" + token = bind_organization(organization_id) + try: + yield organization_id + finally: + release_organization(token) + + +def current_organization() -> str: + """The bound tenant, or raise :class:`TenantNotBound`. + + There is no ``default=`` parameter and there will not be one. A default is a + global key with extra steps. + """ + org = _ORGANIZATION_ID.get() + if org is None: + raise TenantNotBound( + "No organization_id bound on this context. Redis keys are tenant-scoped " + "(saas_multitenancy.md §1.9); bind the tenant at the request/job boundary " + "with acb_common.tenant_redis.organization_scope(org_id) before touching " + "Redis. There is deliberately no global-key fallback." + ) + return org + + +def organization_bound() -> bool: + """True when a tenant is bound. For guards that must not raise.""" + return _ORGANIZATION_ID.get() is not None + + +def _validated_org(organization_id: Any) -> str: + if not isinstance(organization_id, str) or not _ORG_ID_RE.match(organization_id): + raise ValueError( + f"Invalid organization_id for a Redis key: {organization_id!r}. " + "Expected 1-64 chars of [A-Za-z0-9_.-] starting alphanumeric — no ':' " + "(it would forge a prefix segment) and no glob metacharacters (they " + "would widen a SCAN across tenants)." + ) + return organization_id + + +# ── The key types: the only things the client accepts ──────────────────────── + +def _validated_parts(parts: Sequence[Any]) -> tuple[str, ...]: + """Validate key parts. Empty/whitespace/NUL rejected; ``:`` allowed. + + ``:`` is allowed in a *part* because real values already contain it — the + run context's ``instance`` is ``u:`` / ``t:`` — and because a + part cannot escape the tenant prefix: the prefix is prepended, never + interpolated, and ``organization_id`` itself cannot contain ``:``. So the + worst a colon in a part can do is collide with another key *inside the same + tenant*, which is a correctness question, not a tenancy boundary. + """ + out: list[str] = [] + for part in parts: + if not isinstance(part, str): + raise TypeError(f"Redis key parts must be str, got {type(part).__name__}: {part!r}") + if not part or part.strip() != part or "\x00" in part: + raise ValueError( + f"Invalid Redis key part {part!r}: must be non-empty, NUL-free and " + "free of leading/trailing whitespace." + ) + out.append(part) + return tuple(out) + + +@dataclass(frozen=True, slots=True) +class TenantKey: + """A Redis key that provably carries a tenant. The client accepts nothing else. + + Direct construction is not a bypass: ``__post_init__`` runs the same checks + :func:`key` would, including that the tenant matches the bound one. There is + no constructor, classmethod or attribute on this type that yields a key + without the ``cc::`` prefix — :attr:`value` builds it every time from + validated fields rather than storing a string someone could have supplied. + """ + + organization_id: str + namespace: str + parts: tuple[str, ...] = field(default=()) + + def __post_init__(self) -> None: + object.__setattr__(self, "organization_id", _validated_org(self.organization_id)) + if not isinstance(self.namespace, str) or not _NAMESPACE_RE.match(self.namespace): + raise ValueError( + f"Invalid Redis namespace {self.namespace!r}: expected 1-32 chars of " + "[a-z0-9_.-] starting alphanumeric. Nest with parts, not with ':'." + ) + object.__setattr__(self, "parts", _validated_parts(self.parts)) + # Fail closed even on direct construction: a key for a tenant nobody + # bound is exactly the object this module exists to make unobtainable. + bound = current_organization() + if self.organization_id != bound: + raise TenantMismatch( + f"Cannot build a key for organization {self.organization_id!r} while " + f"{bound!r} is bound. Rebind with organization_scope() instead." + ) + + @property + def value(self) -> str: + """The wire key: ``cc::[:...]``.""" + return ":".join((KEY_ROOT, self.organization_id, self.namespace, *self.parts)) + + def __str__(self) -> str: + return self.value + + +@dataclass(frozen=True, slots=True) +class ConsumerGroup: + """A per-tenant Streams consumer group name (§1.9). + + Built by :func:`group`. Every group command on :class:`TenantRedis` demands + one, so ``XGROUP CREATE``/``XREADGROUP``/``XACK`` cannot be issued with a + shared group name — one tenant's consumer can never ACK entries out of + another tenant's pending list. + """ + + organization_id: str + base: str + + def __post_init__(self) -> None: + object.__setattr__(self, "organization_id", _validated_org(self.organization_id)) + if not isinstance(self.base, str) or not _NAMESPACE_RE.match(self.base): + raise ValueError( + f"Invalid consumer-group base {self.base!r}: expected 1-32 chars of " + "[a-z0-9_.-] starting alphanumeric." + ) + bound = current_organization() + if self.organization_id != bound: + raise TenantMismatch( + f"Cannot build a consumer group for {self.organization_id!r} while " + f"{bound!r} is bound." + ) + + @property + def value(self) -> str: + return f"{self.base}:{self.organization_id}" + + def __str__(self) -> str: + return self.value + + +@dataclass(frozen=True, slots=True) +class ScanPattern: + """A ``SCAN``/``KEYS`` match pattern that cannot outgrow its tenant. + + Built by :func:`match`. The fixed segments are glob-escaped and only the + trailing wildcard is free, so ``cc::active:*`` is expressible and + ``cc:*:active:*`` is not. This matters more than it looks: the one scan in + the tree today (``chat.py`` listing active sessions via ``cc:active:*``) + becomes a cross-tenant enumeration the moment a second tenant exists. + """ + + organization_id: str + namespace: str + parts: tuple[str, ...] + suffix: str + + def __post_init__(self) -> None: + object.__setattr__(self, "organization_id", _validated_org(self.organization_id)) + if not isinstance(self.namespace, str) or not _NAMESPACE_RE.match(self.namespace): + raise ValueError(f"Invalid Redis namespace {self.namespace!r}") + object.__setattr__(self, "parts", _validated_parts(self.parts)) + bound = current_organization() + if self.organization_id != bound: + raise TenantMismatch( + f"Cannot build a scan pattern for {self.organization_id!r} while " + f"{bound!r} is bound." + ) + + @property + def value(self) -> str: + fixed = ":".join( + (KEY_ROOT, self.organization_id, self.namespace, *(_glob_escape(p) for p in self.parts)) + ) + return f"{fixed}:{self.suffix}" if self.suffix else fixed + + def __str__(self) -> str: + return self.value + + +def _glob_escape(part: str) -> str: + """Escape Redis glob metacharacters so a fixed segment stays fixed.""" + out = [] + for ch in part: + if ch in "*?[]\\": + out.append("\\") + out.append(ch) + return "".join(out) + + +# ── Public builders ────────────────────────────────────────────────────────── + +def key(namespace: str, *parts: str) -> TenantKey: + """Build ``cc::[:...]``. + + The only key builder there is. Raises :class:`TenantNotBound` when no tenant + is bound — that raise is the feature. + """ + return TenantKey(current_organization(), namespace, tuple(parts)) + + +def group(base: str) -> ConsumerGroup: + """Build the per-tenant consumer group ``:`` (§1.9).""" + return ConsumerGroup(current_organization(), base) + + +def match(namespace: str, *parts: str, suffix: str = "*") -> ScanPattern: + """Build a SCAN pattern rooted at the bound tenant's prefix.""" + return ScanPattern(current_organization(), namespace, tuple(parts), suffix) + + +# ── The client ─────────────────────────────────────────────────────────────── + +class TenantRedis: + """The wrapper client. Every command takes a :class:`TenantKey`, never a ``str``. + + The command surface is deliberately *enumerated*, not proxied through + ``__getattr__``: a catch-all proxy would forward ``client.get("cc:activity")`` + straight to redis-py and hand back exactly the untenanted access this module + removes. Adding a command means adding a method here, which is a two-line + diff and a moment's thought about the key argument — the right price. + + The underlying redis-py client is private on purpose. Reaching for it is the + move ``tests/unit/test_tenant_redis.py``'s ratchet exists to catch. + """ + + __slots__ = ("_client",) + + def __init__(self, client: Any) -> None: + self._client = client + + # -- key discipline ----------------------------------------------------- + + @staticmethod + def _raw(k: Any) -> str: + """Unwrap a :class:`TenantKey`, refusing anything else. The choke point.""" + if not isinstance(k, TenantKey): + raise TypeError( + f"Redis commands take a TenantKey, not {type(k).__name__}. Build one with " + "acb_common.tenant_redis.key(namespace, *parts) — a hand-written key " + "string is the untenanted access this client exists to prevent " + "(saas_multitenancy.md §0.9.4)." + ) + bound = current_organization() + if k.organization_id != bound: + raise TenantMismatch( + f"Key {k.value!r} belongs to organization {k.organization_id!r} but " + f"{bound!r} is bound on this context." + ) + return k.value + + @staticmethod + def _raw_group(g: Any) -> str: + if not isinstance(g, ConsumerGroup): + raise TypeError( + f"Consumer-group commands take a ConsumerGroup, not {type(g).__name__}. " + "Build one with acb_common.tenant_redis.group(base) — §1.9 requires " + "separate consumer groups per tenant." + ) + bound = current_organization() + if g.organization_id != bound: + raise TenantMismatch( + f"Consumer group {g.value!r} belongs to {g.organization_id!r} but " + f"{bound!r} is bound on this context." + ) + return g.value + + def _raw_streams(self, streams: Mapping[Any, Any]) -> dict[str, Any]: + return {self._raw(k): v for k, v in streams.items()} + + # -- builders, so a caller needs only the client ------------------------ + + def key(self, namespace: str, *parts: str) -> TenantKey: + return key(namespace, *parts) + + def group(self, base: str) -> ConsumerGroup: + return group(base) + + def match(self, namespace: str, *parts: str, suffix: str = "*") -> ScanPattern: + return match(namespace, *parts, suffix=suffix) + + # -- strings / generic keys -------------------------------------------- + + async def get(self, k: TenantKey) -> Any: + return await self._client.get(self._raw(k)) + + async def set(self, k: TenantKey, value: Any, **kwargs: Any) -> Any: + return await self._client.set(self._raw(k), value, **kwargs) + + async def setex(self, k: TenantKey, seconds: int, value: Any) -> Any: + return await self._client.setex(self._raw(k), seconds, value) + + async def delete(self, *keys: TenantKey) -> Any: + return await self._client.delete(*(self._raw(k) for k in keys)) + + async def exists(self, *keys: TenantKey) -> Any: + return await self._client.exists(*(self._raw(k) for k in keys)) + + async def expire(self, k: TenantKey, seconds: int) -> Any: + return await self._client.expire(self._raw(k), seconds) + + async def ttl(self, k: TenantKey) -> Any: + return await self._client.ttl(self._raw(k)) + + async def incr(self, k: TenantKey, amount: int = 1) -> Any: + return await self._client.incr(self._raw(k), amount) + + # -- lists (steer signals are a durable list today) --------------------- + + async def rpush(self, k: TenantKey, *values: Any) -> Any: + return await self._client.rpush(self._raw(k), *values) + + async def lrange(self, k: TenantKey, start: int, end: int) -> Any: + return await self._client.lrange(self._raw(k), start, end) + + async def llen(self, k: TenantKey) -> Any: + return await self._client.llen(self._raw(k)) + + # -- hashes (cost rollups, room presence) ------------------------------- + + async def hset(self, k: TenantKey, *args: Any, **kwargs: Any) -> Any: + return await self._client.hset(self._raw(k), *args, **kwargs) + + async def hget(self, k: TenantKey, name: str) -> Any: + return await self._client.hget(self._raw(k), name) + + async def hgetall(self, k: TenantKey) -> Any: + return await self._client.hgetall(self._raw(k)) + + async def hdel(self, k: TenantKey, *names: str) -> Any: + return await self._client.hdel(self._raw(k), *names) + + async def hincrby(self, k: TenantKey, name: str, amount: int = 1) -> Any: + return await self._client.hincrby(self._raw(k), name, amount) + + async def hincrbyfloat(self, k: TenantKey, name: str, amount: float = 1.0) -> Any: + return await self._client.hincrbyfloat(self._raw(k), name, amount) + + # -- streams ------------------------------------------------------------ + + async def xadd(self, k: TenantKey, fields: Mapping[str, Any], **kwargs: Any) -> Any: + return await self._client.xadd(self._raw(k), fields, **kwargs) + + async def xlen(self, k: TenantKey) -> Any: + return await self._client.xlen(self._raw(k)) + + async def xrange(self, k: TenantKey, *args: Any, **kwargs: Any) -> Any: + return await self._client.xrange(self._raw(k), *args, **kwargs) + + async def xrevrange(self, k: TenantKey, *args: Any, **kwargs: Any) -> Any: + return await self._client.xrevrange(self._raw(k), *args, **kwargs) + + async def xread(self, streams: Mapping[TenantKey, Any], **kwargs: Any) -> Any: + return await self._client.xread(self._raw_streams(streams), **kwargs) + + async def xdel(self, k: TenantKey, *ids: str) -> Any: + return await self._client.xdel(self._raw(k), *ids) + + # -- streams, consumer groups (per tenant, §1.9) ------------------------ + + async def xgroup_create(self, k: TenantKey, g: ConsumerGroup, **kwargs: Any) -> Any: + return await self._client.xgroup_create(self._raw(k), self._raw_group(g), **kwargs) + + async def xreadgroup( + self, + g: ConsumerGroup, + consumername: str, + streams: Mapping[TenantKey, Any], + **kwargs: Any, + ) -> Any: + return await self._client.xreadgroup( + groupname=self._raw_group(g), + consumername=consumername, + streams=self._raw_streams(streams), + **kwargs, + ) + + async def xack(self, k: TenantKey, g: ConsumerGroup, *ids: str) -> Any: + return await self._client.xack(self._raw(k), self._raw_group(g), *ids) + + async def xpending(self, k: TenantKey, g: ConsumerGroup, **kwargs: Any) -> Any: + return await self._client.xpending(self._raw(k), self._raw_group(g), **kwargs) + + async def xclaim(self, k: TenantKey, g: ConsumerGroup, *args: Any, **kwargs: Any) -> Any: + return await self._client.xclaim(self._raw(k), self._raw_group(g), *args, **kwargs) + + # -- pub/sub (the cross-worker control bus) ----------------------------- + + async def publish(self, channel: TenantKey, message: Any) -> Any: + return await self._client.publish(self._raw(channel), message) + + async def subscribe(self, *channels: TenantKey) -> Any: + """Return a redis-py pubsub already subscribed to tenant-scoped channels.""" + pubsub = self._client.pubsub() + await pubsub.subscribe(*(self._raw(c) for c in channels)) + return pubsub + + # -- scan --------------------------------------------------------------- + + async def scan_iter(self, pattern: ScanPattern, *, count: int = 200) -> AsyncIterator[str]: + """Iterate keys under this tenant's prefix. Takes a pattern, never a string.""" + if not isinstance(pattern, ScanPattern): + raise TypeError( + f"scan_iter takes a ScanPattern, not {type(pattern).__name__}. Build one " + "with match(namespace, *parts) — an untenanted match string turns a scan " + "into a cross-tenant enumeration." + ) + bound = current_organization() + if pattern.organization_id != bound: + raise TenantMismatch( + f"Scan pattern belongs to {pattern.organization_id!r} but {bound!r} is bound." + ) + async for raw in self._client.scan_iter(match=pattern.value, count=count): + yield raw + + # -- pipeline ----------------------------------------------------------- + + def pipeline(self, transaction: bool = False) -> TenantPipeline: + return TenantPipeline(self._client.pipeline(transaction=transaction)) + + +class TenantPipeline: + """A pipeline with the same key discipline as :class:`TenantRedis`. + + Separate class rather than a shared mixin because redis-py's pipeline + commands are *synchronous* (they buffer; only ``execute`` awaits), and a + pipeline that quietly accepted a raw string would be the easiest hole to + leave open — the cost rollup in ``activity.py`` is written entirely through + one. + """ + + __slots__ = ("_pipe",) + + def __init__(self, pipe: Any) -> None: + self._pipe = pipe + + def hincrbyfloat(self, k: TenantKey, name: str, amount: float) -> TenantPipeline: + self._pipe.hincrbyfloat(TenantRedis._raw(k), name, amount) + return self + + def hincrby(self, k: TenantKey, name: str, amount: int = 1) -> TenantPipeline: + self._pipe.hincrby(TenantRedis._raw(k), name, amount) + return self + + def hset(self, k: TenantKey, *args: Any, **kwargs: Any) -> TenantPipeline: + self._pipe.hset(TenantRedis._raw(k), *args, **kwargs) + return self + + def set(self, k: TenantKey, value: Any, **kwargs: Any) -> TenantPipeline: + self._pipe.set(TenantRedis._raw(k), value, **kwargs) + return self + + def delete(self, *keys: TenantKey) -> TenantPipeline: + self._pipe.delete(*(TenantRedis._raw(k) for k in keys)) + return self + + def expire(self, k: TenantKey, seconds: int) -> TenantPipeline: + self._pipe.expire(TenantRedis._raw(k), seconds) + return self + + async def execute(self) -> Any: + return await self._pipe.execute() + + +# ── Pooled client (one per process, like acb_common.db's engine) ───────────── + +#: Process-wide and tenant-agnostic *by design*: the connection pool is shared, +#: the KEYS are what carry the tenant. A pool per tenant would multiply +#: connections by customer count for no isolation gain — Redis has no per-key +#: authorisation to enforce anyway, so isolation must come from the key, and +#: does. Mirrors ``activity.py``'s pooled client settings. +_POOL: Any = None + + +def get_tenant_redis() -> TenantRedis: + """The shared tenant-aware client for this process. + + Does **not** require a tenant to be bound — the client is tenant-agnostic; + every *key* it accepts is not. Binding is checked at key-build and at + command time, which is where a missing binding is actually a bug. + """ + global _POOL + if _POOL is None: + _POOL = aioredis.from_url( + get_settings().redis_url, + decode_responses=True, + max_connections=16, + health_check_interval=30, + ) + return TenantRedis(_POOL) + + +def reset_pool_for_tests() -> None: + """Drop the cached pool. Tests only — there is no runtime reason to call it.""" + global _POOL + _POOL = None diff --git a/packages/acb_llm/acb_llm/key_store.py b/packages/acb_llm/acb_llm/key_store.py index 01918c318..a8d2229ad 100644 --- a/packages/acb_llm/acb_llm/key_store.py +++ b/packages/acb_llm/acb_llm/key_store.py @@ -54,7 +54,12 @@ class ProviderKeyStore: def __init__(self) -> None: self._fernet: Fernet | None = None - self._cache: dict[str, str] = {} # provider → plain text key (in-memory) + # MT-0d: keyed by (organization_id, provider) — NOT provider alone. + # A provider-keyed cache is a cross-tenant leak that no amount of + # correctness in the SQL below would catch: the second tenant asking for + # "openai" would be served the first tenant's decrypted key straight + # from memory, without a query ever running. + self._cache: dict[tuple[str, str], str] = {} @property def _f(self) -> Fernet: @@ -116,14 +121,53 @@ def _sync() -> list[dict[str, Any]]: return await asyncio.to_thread(_sync) - async def get(self, provider: str) -> str: - """Return the plain-text API key for a provider, or '' if not set.""" - # Check in-memory cache first - if provider in self._cache: - return self._cache[provider] + async def _resolve_org(self, organization_id: str | None) -> str: + """The organization a credential read/write belongs to. Fails CLOSED. + + MT-0d (``saas_multitenancy.md`` §6.3). ``provider_keys`` was + ``provider TEXT PRIMARY KEY`` — one key per provider for the whole box. + That was correct under D11 (one deployment per tenant) and is a + cross-tenant leak under D15. + + Passing ``organization_id`` explicitly is always right. ``None`` means + "the sole organization", which **only resolves while there is exactly + one** — every one of the ~20 existing call sites relies on it today and + keeps working unchanged, and the moment a second organization is created + it returns ``""`` and every untenanted read yields nothing. + + That is the point, and it is why this is not a "default org" lookup: a + default-org fallback would keep answering *after* tenant #2 arrived, and + would serve the operator's keys to a customer. Failing closed turns that + into a visibly missing key at exactly the moment MT-1 must supply a real + tenant, instead of a silent leak nobody notices. + """ + if organization_id: + return str(organization_id) + rows = await self._execute( + "SELECT id FROM organization WHERE (SELECT count(*) FROM organization) = 1" + ) + return str(rows[0]["id"]) if rows else "" + + async def get(self, provider: str, organization_id: str | None = None) -> str: + """Return the plain-text API key for a provider, or '' if not set. + + MT-0d: scoped to *organization_id*, or to the sole organization when it + is omitted (see :meth:`_resolve_org` — that resolution fails closed once + a second tenant exists). + """ + org = await self._resolve_org(organization_id) + if not org: + _log.warning("key_store.no_tenant_resolved", provider=provider) + return "" + + cache_key = (org, provider) + if cache_key in self._cache: + return self._cache[cache_key] rows = await self._execute( - "SELECT encrypted FROM provider_keys WHERE provider = :provider", + "SELECT encrypted FROM provider_keys " + " WHERE organization_id = :org_id AND provider = :provider", + org_id=org, provider=provider, ) if not rows: @@ -131,7 +175,7 @@ async def get(self, provider: str) -> str: try: plain = self._f.decrypt(base64.urlsafe_b64decode(rows[0]["encrypted"])).decode("utf-8") - self._cache[provider] = plain + self._cache[cache_key] = plain return plain except Exception: _log.warning("key_store.decrypt_failed", provider=provider) @@ -157,8 +201,9 @@ async def put( api_key: str, credential_type: str = "llm", service: str | None = None, + organization_id: str | None = None, ) -> None: - """Store an encrypted API key (upsert). + """Store an encrypted API key (upsert), scoped to an organization. Args: provider: Unique key identifier (e.g. 'openai', 'zoho-crm:client_id'). @@ -166,10 +211,22 @@ async def put( credential_type: 'llm' (default) or 'integration'. service: For integration keys, the service name (e.g. 'zoho-crm'). Defaults to provider when not given. + organization_id: MT-0d — the owning org. Omitted resolves to the sole + organization and **raises** once a second one exists, which + is deliberate: writing a credential with no owner is how a + key ends up readable by the wrong tenant, and a write is a + far better place to fail loudly than a read. """ if not api_key.strip(): raise ValueError("api_key cannot be empty") + org = await self._resolve_org(organization_id) + if not org: + raise RuntimeError( + "key_store.put requires an organization_id once more than one " + "organization exists (MT-0d / saas_multitenancy.md §6.3)" + ) + encrypted = base64.urlsafe_b64encode( self._f.encrypt(api_key.encode("utf-8")) ).decode("ascii") @@ -179,108 +236,120 @@ async def put( await self._execute( """ INSERT INTO provider_keys - (provider, encrypted, credential_type, service, updated_at) - VALUES (:provider, :encrypted, :credential_type, :service, now()) - ON CONFLICT (provider) DO UPDATE + (organization_id, provider, encrypted, credential_type, service, updated_at) + VALUES (:org_id, :provider, :encrypted, :credential_type, :service, now()) + ON CONFLICT (organization_id, provider) DO UPDATE SET encrypted = :encrypted, credential_type = :credential_type, service = :service, updated_at = now() """, + org_id=org, provider=provider, encrypted=encrypted, credential_type=credential_type, service=svc, ) - self._cache[provider] = api_key + self._cache[(org, provider)] = api_key _log.info("key_store.put", provider=provider, credential_type=credential_type) - async def delete(self, provider: str) -> None: - """Remove a provider's API key.""" + async def delete(self, provider: str, organization_id: str | None = None) -> None: + """Remove a provider's API key (MT-0d: within one organization only).""" + org = await self._resolve_org(organization_id) + if not org: + _log.warning("key_store.delete_no_tenant", provider=provider) + return await self._execute( - "DELETE FROM provider_keys WHERE provider = :provider", + "DELETE FROM provider_keys " + " WHERE organization_id = :org_id AND provider = :provider", + org_id=org, provider=provider, ) - self._cache.pop(provider, None) + self._cache.pop((org, provider), None) _log.info("key_store.delete", provider=provider) - async def get_all(self) -> dict[str, str]: - """Return all stored provider keys (decrypted).""" - rows = await self._execute("SELECT provider, encrypted FROM provider_keys") + async def _decrypt_rows(self, org: str, rows: list[dict[str, Any]]) -> dict[str, str]: + """Decrypt ``(provider, encrypted)`` rows, using and filling the cache. + + MT-0d: the cache key is ``(org, provider)``. It was ``provider`` alone, + which would have served the first tenant's decrypted key to the second + without a query ever running — a leak no amount of correct SQL catches. + """ result: dict[str, str] = {} for row in rows: provider = row["provider"] - if provider in self._cache: - result[provider] = self._cache[provider] + cache_key = (org, provider) + if cache_key in self._cache: + result[provider] = self._cache[cache_key] continue try: - plain = self._f.decrypt(base64.urlsafe_b64decode(row["encrypted"])).decode("utf-8") - self._cache[provider] = plain + plain = self._f.decrypt( + base64.urlsafe_b64decode(row["encrypted"]) + ).decode("utf-8") + self._cache[cache_key] = plain result[provider] = plain except Exception: _log.warning("key_store.decrypt_failed", provider=provider) return result - async def get_by_type(self, credential_type: str) -> dict[str, str]: - """Return all keys of a given credential_type (decrypted). + async def get_all(self, organization_id: str | None = None) -> dict[str, str]: + """Return this organization's stored provider keys (decrypted).""" + org = await self._resolve_org(organization_id) + if not org: + return {} + rows = await self._execute( + "SELECT provider, encrypted FROM provider_keys " + " WHERE organization_id = :org_id", + org_id=org, + ) + return await self._decrypt_rows(org, rows) + + async def get_by_type( + self, credential_type: str, organization_id: str | None = None, + ) -> dict[str, str]: + """Return this org's keys of a given credential_type (decrypted). Args: credential_type: 'llm' or 'integration'. + organization_id: MT-0d — omitted resolves to the sole organization. Returns: {provider: plain_text_key, ...} """ + org = await self._resolve_org(organization_id) + if not org: + return {} rows = await self._execute( "SELECT provider, encrypted FROM provider_keys " - "WHERE credential_type = :credential_type", + " WHERE organization_id = :org_id AND credential_type = :credential_type", + org_id=org, credential_type=credential_type, ) - result: dict[str, str] = {} - for row in rows: - provider = row["provider"] - if provider in self._cache: - result[provider] = self._cache[provider] - continue - try: - plain = self._f.decrypt( - base64.urlsafe_b64decode(row["encrypted"]) - ).decode("utf-8") - self._cache[provider] = plain - result[provider] = plain - except Exception: - _log.warning("key_store.decrypt_failed", provider=provider) - return result + return await self._decrypt_rows(org, rows) - async def get_by_service(self, service: str) -> dict[str, str]: - """Return all keys for a given service (decrypted). + async def get_by_service( + self, service: str, organization_id: str | None = None, + ) -> dict[str, str]: + """Return this org's keys for a given service (decrypted). Args: service: Service name, e.g. 'zoho-crm', 'clickup'. + organization_id: MT-0d — omitted resolves to the sole organization. Returns: {provider: plain_text_key, ...} """ + org = await self._resolve_org(organization_id) + if not org: + return {} rows = await self._execute( "SELECT provider, encrypted FROM provider_keys " - "WHERE service = :service", + " WHERE organization_id = :org_id AND service = :service", + org_id=org, service=service, ) - result: dict[str, str] = {} - for row in rows: - provider = row["provider"] - if provider in self._cache: - result[provider] = self._cache[provider] - continue - try: - plain = self._f.decrypt( - base64.urlsafe_b64decode(row["encrypted"]) - ).decode("utf-8") - self._cache[provider] = plain - result[provider] = plain - except Exception: - _log.warning("key_store.decrypt_failed", provider=provider) - return result + return await self._decrypt_rows(org, rows) async def configure_integrations(self) -> None: """Load all integration credentials into os.environ. diff --git a/packages/acb_llm/acb_llm/model_config.py b/packages/acb_llm/acb_llm/model_config.py index 71a964e11..bd8809317 100644 --- a/packages/acb_llm/acb_llm/model_config.py +++ b/packages/acb_llm/acb_llm/model_config.py @@ -43,44 +43,77 @@ def _conninfo() -> str: ) -def load_blob(key: str, default: Any = None) -> Any: - """Return the JSON blob stored under ``key``. +#: MT-0d — resolve the owning organization for a config read/write. +#: Mirrors ``key_store._resolve_org`` deliberately: ``None`` means "the sole +#: organization", which stops resolving the moment a second one exists, so an +#: untenanted read fails CLOSED rather than serving another tenant's model +#: config. A "default org" fallback would keep answering after tenant #2 and is +#: exactly the leak MT-0d closes (``saas_multitenancy.md`` §6.3). +_SOLE_ORG_SQL = "SELECT id FROM organization WHERE (SELECT count(*) FROM organization) = 1" - Returns ``default`` when the row is absent or the DB is unreachable, so - callers can fall back to a legacy file for one-time seeding. + +def _resolve_org(cur: Any, organization_id: str | None) -> str: + if organization_id: + return str(organization_id) + cur.execute(_SOLE_ORG_SQL) + row = cur.fetchone() + return str(row[0]) if row else "" + + +def load_blob( + key: str, default: Any = None, organization_id: str | None = None, +) -> Any: + """Return the JSON blob stored under ``key`` for an organization. + + Returns ``default`` when the row is absent, no tenant resolves, or the DB is + unreachable, so callers can fall back to a legacy file for one-time seeding. """ - import psycopg # noqa: PLC0415 + import psycopg try: - with psycopg.connect(_conninfo(), connect_timeout=5) as conn: - with conn.cursor() as cur: - cur.execute( - "SELECT value FROM model_config WHERE key = %s", (key,) - ) - row = cur.fetchone() + with psycopg.connect(_conninfo(), connect_timeout=5) as conn, conn.cursor() as cur: + org = _resolve_org(cur, organization_id) + if not org: + _log.warning("model_config.no_tenant_resolved", key=key) + return default + cur.execute( + "SELECT value FROM model_config " + " WHERE organization_id = %s AND key = %s", + (org, key), + ) + row = cur.fetchone() if row is None or row[0] is None: return default val = row[0] # psycopg adapts jsonb → dict/list, but tolerate a text value too. return json.loads(val) if isinstance(val, str) else val - except Exception as exc: # noqa: BLE001 + except Exception as exc: _log.warning("model_config.load_failed", key=key, error=str(exc)) return default -def save_blob(key: str, value: Any) -> None: - """Upsert a JSON blob under ``key``. Raises on failure so the caller can - surface a real save error instead of silently losing the change.""" - import psycopg # noqa: PLC0415 +def save_blob(key: str, value: Any, organization_id: str | None = None) -> None: + """Upsert a JSON blob under ``key`` for an organization. - with psycopg.connect(_conninfo(), connect_timeout=5) as conn: - with conn.cursor() as cur: - cur.execute( - "INSERT INTO model_config (key, value, updated_at) " - "VALUES (%s, %s::jsonb, now()) " - "ON CONFLICT (key) DO UPDATE " - "SET value = EXCLUDED.value, updated_at = now()", - (key, json.dumps(value)), + Raises on failure so the caller can surface a real save error instead of + silently losing the change — including when no tenant resolves, because a + write with no owner is how a row ends up readable by the wrong tenant. + """ + import psycopg + + with psycopg.connect(_conninfo(), connect_timeout=5) as conn, conn.cursor() as cur: + org = _resolve_org(cur, organization_id) + if not org: + raise RuntimeError( + "model_config.save_blob requires an organization_id once more " + "than one organization exists (MT-0d)" ) + cur.execute( + "INSERT INTO model_config (organization_id, key, value, updated_at) " + "VALUES (%s, %s, %s::jsonb, now()) " + "ON CONFLICT (organization_id, key) DO UPDATE " + "SET value = EXCLUDED.value, updated_at = now()", + (org, key, json.dumps(value)), + ) conn.commit() _log.info("model_config.saved", key=key) diff --git a/packages/acb_skills/acb_skills/addendum.py b/packages/acb_skills/acb_skills/addendum.py index 940875242..d4f14a5b0 100644 --- a/packages/acb_skills/acb_skills/addendum.py +++ b/packages/acb_skills/acb_skills/addendum.py @@ -271,7 +271,7 @@ class Section(NamedTuple): - **recall_notes(path, query?)** — Read back a notes file, optionally filtered by a search query. Use to restore context from previous sessions. """), Section("history", ("query_history",), """### Conversation history -- **query_history(query)** — Run a SELECT-only SQL query against the chat history database (tables: ``chat_session``, ``chat_message``). Use to recall what was discussed in prior sessions, find past decisions, or resume work on a known thread. +- **query_history(search?, thread_id?, agent_name?, since_days?, limit?)** — Recall past conversations by search criteria. Use to remember what was discussed in prior sessions, find a decision that was made before, or resume work on a known thread. Takes **search terms, not SQL** — e.g. ``query_history(search="pricing", since_days=30)``. """), Section("coding", ("github_search", "github_repo_search"), """### GitHub code search - **github_search(query, scope?, maxResults?)** — Lexical search across public GitHub repositories. Supports ``language:python``, ``repo:owner/name``, ``path:src/`` filters. @@ -383,7 +383,7 @@ class MandatoryLine(NamedTuple): "save_note(path,fact), recall_notes(path,query?) — repo-scoped working memory" )), Section("history", ("query_history",), ( - "query_history(sql) — SELECT-only query against chat history DB" + "query_history(search?,thread_id?,agent_name?,since_days?,limit?) — recall past conversations" )), Section("coding", ("github_search", "github_repo_search"), ( "github_search(q,scope?,max?), github_repo_search(repo,q?) — code search" diff --git a/packages/acb_skills/acb_skills/code_tools.py b/packages/acb_skills/acb_skills/code_tools.py index c7f2543b2..4eb2fbce1 100644 --- a/packages/acb_skills/acb_skills/code_tools.py +++ b/packages/acb_skills/acb_skills/code_tools.py @@ -80,18 +80,24 @@ def _script_env() -> dict[str, str]: The base allowlist is secret-free (deny-pattern on top). On top of it, the canonical env vars of the integrations this agent declared in its ``config.json`` — and that resolved for this run — are passed through - (``acb_skills.integrations.FIELD_TO_ENV``; the executor injects them into - the run env, scoped by a restore token). So a script gets the ClickUp token - *its own agent* declared, but not the gateway master key or the DB URL. - - Concurrency caveat (same honest limit the executor documents for - ``_inject_integrations_to_env``): both ``os.environ`` and the declared-list - (``_WRITE_ARTIFACT_CONTEXT["integrations"]``) are process-global, so under - OVERLAPPING in-process runs of different agents the scoping is best-effort — - a concurrent run can transiently widen what a script sees. A true per-run - boundary is the Tier-2 container/subprocess env (BO-7); this Tier-0 layer - removes permanent accumulation and scopes to the declared set, it is not a - hard multi-tenant isolation guarantee. + (``acb_skills.integrations.FIELD_TO_ENV``). So a script gets the ClickUp + token *its own agent* declared, but not the gateway master key or the DB URL. + + **MT-0a: the credential values come from the run's ContextVar binding** + (``integrations.credential``), not from ``os.environ``. The executor used to + export them into the process environment, which meant two overlapping runs + shared them for the overlap window — a within-org concern under one tenant + and a credential leak under two (`saas_multitenancy.md` §6.1). A ContextVar + is per-task, so a concurrent run cannot widen what this script sees. + + Residual caveat, narrowed but not gone: the declared-*list* + (``_WRITE_ARTIFACT_CONTEXT["integrations"]``) is still a process-global dict + despite its docstring calling itself coroutine-local, so a concurrent run can + still transiently widen *which names* are looked up. That is now much less + dangerous than it was — ``credential()`` resolves against **this** context's + binding, so a widened name list yields nothing unless this run also holds + that credential. Making the declared-list itself a ContextVar is the + remaining half; a true per-run boundary is the Tier-2 container env (MT-0c). """ env = { k: v for k, v in os.environ.items() @@ -100,10 +106,14 @@ def _script_env() -> dict[str, str]: declared = _declared_integrations() if declared: try: - from acb_skills.integrations import env_var_names # noqa: PLC0415 + from acb_skills.integrations import ( + credential, + env_var_names, + ) for var in env_var_names(declared): - if var in os.environ: - env[var] = os.environ[var] + val = credential(var) + if val: + env[var] = val except ImportError: pass env.setdefault("PYTHONUNBUFFERED", "1") @@ -141,7 +151,7 @@ async def _sweep_to_blob_store( The tree walk + file reads (up to ``_SWEEP_MAX_FILES`` × ``_SWEEP_MAX_BYTES``) run OFF the event loop so a large sweep can't stall every concurrent run. """ - import asyncio # noqa: PLC0415 + import asyncio cutoff = since - _SWEEP_MTIME_SLACK @@ -168,20 +178,20 @@ def _collect() -> list[tuple[str, bytes]]: if st.st_mtime < cutoff or st.st_size > _SWEEP_MAX_BYTES: continue out.append((p.relative_to(root).as_posix(), p.read_bytes())) - except Exception: # noqa: BLE001 + except Exception: continue return out try: collected = await asyncio.to_thread(_collect) - except Exception: # noqa: BLE001 + except Exception: return 0 mirrored = 0 for rel, data in collected: try: await mirror_to_blob_store(rel, data, actor="agent") mirrored += 1 - except Exception: # noqa: BLE001 + except Exception: continue return mirrored @@ -291,7 +301,7 @@ def _commit_repo_changes(root: Path, task: str) -> str | None: is not a git repo, the tree is clean, or any git step fails (best-effort — never raises). """ - import subprocess # noqa: PLC0415 + import subprocess if not (root / ".git").exists(): return None @@ -327,7 +337,7 @@ def _git(*args: str) -> subprocess.CompletedProcess[str]: return None sha = _git("rev-parse", "--short", "HEAD").stdout.strip() return sha or None - except Exception: # noqa: BLE001 — a git hiccup must never fail the tool + except Exception: return None @@ -378,7 +388,7 @@ async def code_task(task: str) -> str: declared = _declared_integrations() if declared: try: - from acb_skills.integrations import FIELD_TO_ENV # noqa: PLC0415 + from acb_skills.integrations import FIELD_TO_ENV lines = [ f"- {svc}: " + ", ".join(v for _, v in FIELD_TO_ENV.get(svc, [])) for svc in declared @@ -407,7 +417,7 @@ async def code_task(task: str) -> str: swept = 0 # Fail-safe: commit any repo-source edits the session left uncommitted so # the approval pipeline sees them and the next loader pull can't wipe them. - import asyncio # noqa: PLC0415 + import asyncio committed = await asyncio.to_thread(_commit_repo_changes, root, task) commit_note = ( f"\n[repo changes committed locally as {committed} — queued for " diff --git a/packages/acb_skills/acb_skills/history_tools.py b/packages/acb_skills/acb_skills/history_tools.py index abafe683f..b66cc7306 100644 --- a/packages/acb_skills/acb_skills/history_tools.py +++ b/packages/acb_skills/acb_skills/history_tools.py @@ -1,111 +1,167 @@ -"""Session-history query tool — agents can recall past conversations. - -Provides ``query_history`` which mirrors VS Code Copilot's ``session_store_sql`` -tool. The agent can query the chat session database to recall what was -discussed in prior sessions with the same user. - -Design ------- -- Accepts a SQL ``SELECT`` query against the ``chat_session`` and - ``chat_message`` tables. -- Returns a JSON array of matching rows, truncated for safety. -- Only SELECT queries are allowed; any write attempt is rejected. -- The tool requires Postgres access via ``acb_graph``. - -Usage by agents:: - - await query_history( - "SELECT role, content FROM chat_message " - "WHERE thread_id = 'abc123' ORDER BY created_at DESC LIMIT 5" - ) +"""Session-history recall for agents — parameterised, never SQL. + +MT-0c-1 (``saas_multitenancy.md`` §0.9.3): **no agent ever gets a raw-SQL tool.** +That is not a style preference; it is a stated condition on the pooled tenancy +decision. An agent that can compose SQL can read any table the connection can +reach, and agents here execute model-generated tool calls over content ingested +from email and WhatsApp. + +What this module used to be +--------------------------- +``query_history(query: str)`` took a **model-generated SQL string** and executed +it via ``acb_graph.get_session()``. Its guard was a keyword-substring check, and +it was wrong in both directions — measured 2026-08-08: + +* **False positive.** ``SELECT role, content, created_at FROM chat_message`` — + *the tool's own documented example* — was **rejected**, because ``CREATED_AT`` + contains the substring ``CREATE``. Any query selecting a ``created_at`` column + failed, which is most of them. +* **False negative, and this is the one that matters.** + ``SELECT * FROM provider_keys`` passed the guard cleanly. So did every other + table in the database. The allowlist constrained *verbs*; nothing constrained + *tables*. + +Under one organization that is a within-org visibility hole. Under the pooled +tenant boundary (D15) it is a cross-tenant read primitive — and it reaches the +database through ``acb_graph``, which is connection path 4 in +``saas_multitenancy.md`` §0.1: the **sync** ``create_engine`` the seam ratchet +never inspected. + +What it is now +-------------- +A parameterised search over exactly the two tables it always documented. The +model supplies *values*, never syntax; the SQL is a fixed string in this module +with bound parameters. There is no query string to sanitise because there is no +query string. + +Scope narrowed at the same time: results are limited to the acting member's own +sessions when the run context names one. The previous tool could read any +member's conversations — its own docstring example did exactly that — which the +visibility ladder (``tenancy_and_visibility.md`` §3.3, chat is +private/people/org) never permitted. """ from __future__ import annotations import json as _json +#: Hard ceilings. The model may ask for less, never more. +_MAX_LIMIT = 20 +_CONTENT_CAP = 500 + +#: The only two tables reachable from this tool, ever. Not configurable — a +#: table name that arrives as data is the hole this module was rewritten to +#: close. +_SEARCH_SQL = """ +SELECT m.role, + m.content, + m.created_at, + s.thread_id, + s.agent_name, + s.title + FROM chat_message m + JOIN chat_session s ON s.thread_id = m.thread_id + WHERE (:thread_id IS NULL OR s.thread_id = :thread_id) + AND (:agent_name IS NULL OR s.agent_name = :agent_name) + AND (:user_id IS NULL OR s.user_id = :user_id) + AND (:search IS NULL OR m.content ILIKE :search_like) + AND (:since_days IS NULL OR m.created_at >= now() - make_interval(days => :since_days)) + ORDER BY m.created_at DESC + LIMIT :limit +""" + -async def query_history(query: str) -> str: - """Query past conversation history from the chat database. +def _acting_user() -> str | None: + """The member this run acts for, if the run context names one. + + Returns ``None`` for an unattended run (a webhook or cron has no member), + which widens the search to the whole deployment. That is the pre-existing + behaviour for those runs and is left unchanged here deliberately: narrowing + it is a *visibility* decision owned by ``tenancy_and_visibility.md`` §3.3, + not something this ticket should change silently. Under D15 the tenant + boundary is enforced beneath this by RLS (MT-1), not by this predicate. + """ + try: + from acb_common import get_run_context - Call this when you need context from earlier conversations — what was - discussed last week, what decisions were made, what tasks were pending. + return (get_run_context() or {}).get("user") or None + except Exception: + return None - **Available tables:** - - ``chat_session`` — columns: ``id``, ``thread_id``, ``agent_name``, - ``user_id``, ``title``, ``created_at``, ``updated_at`` - - ``chat_message`` — columns: ``id``, ``thread_id``, ``role``, - ``content``, ``created_at``, ``tool_events`` - **Use this tool when:** - - The user references a past conversation or decision - - You need to recall what was discussed or decided previously - - You are resuming work on a known thread +async def query_history( + search: str | None = None, + thread_id: str | None = None, + agent_name: str | None = None, + since_days: int | None = None, + limit: int = 10, +) -> str: + """Recall past conversations — what was discussed, decided, or left pending. - **Safety:** Only ``SELECT`` queries are allowed. Results are capped at - 20 rows and content is truncated to 500 chars per row. + Call this when the user references an earlier conversation, when you need a + decision that was made before, or when you are resuming work on a thread. Args: - query: A SQL ``SELECT`` statement. Must start with ``SELECT`` - (case-insensitive). ``INSERT``, ``UPDATE``, ``DELETE``, - ``DROP``, and other mutations are rejected. + search: Text to look for in message content (case-insensitive, + substring). Omit to browse rather than search. + thread_id: Restrict to one conversation thread. + agent_name: Restrict to conversations with one agent, e.g. + ``"orchestrator"``. + since_days: Only messages from the last N days. + limit: How many messages to return, newest first. Capped at 20. Returns: - JSON array of matching rows, or an error message. - - Example:: + A JSON array of ``{role, content, created_at, thread_id, agent_name, + title}`` objects, newest first. Long content is truncated. - await query_history( - "SELECT role, content, created_at FROM chat_message " - "WHERE thread_id = (SELECT id FROM chat_session " - "WHERE user_id = 'vijay@fracktal.in' " - "ORDER BY updated_at DESC LIMIT 1) " - "ORDER BY created_at ASC LIMIT 10" - ) + Notes: + This tool takes **search criteria, not SQL**. It reads conversation + history only — no other data is reachable through it. """ - q = query.strip() - if not q.upper().startswith("SELECT"): - return ( - "Error: only SELECT queries are allowed. " - "Got: " + q[:50] + ("..." if len(q) > 50 else "") - ) - - # Reject dangerous keywords even in SELECT. - dangerous = {"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", - "CREATE", "TRUNCATE", "EXEC", "EXECUTE"} - q_upper = q.upper() - for kw in dangerous: - if kw in q_upper: - return f"Error: keyword {kw} is not allowed in query_history" - - # Also reject multi-statement queries (semicolons outside strings). - # Simple heuristic: split on ; and check each part. - parts = q.split(";") - non_empty = [p.strip() for p in parts if p.strip()] - if len(non_empty) > 1: - return "Error: only one SQL statement is allowed" + try: + lim = max(1, min(int(limit or 10), _MAX_LIMIT)) + except (TypeError, ValueError): + lim = 10 + + days: int | None = None + if since_days is not None: + try: + days = max(1, int(since_days)) + except (TypeError, ValueError): + days = None + + term = (search or "").strip() or None + + params = { + "thread_id": (thread_id or "").strip() or None, + "agent_name": (agent_name or "").strip() or None, + "user_id": _acting_user(), + "search": term, + "search_like": f"%{term}%" if term else None, + "since_days": days, + "limit": lim, + } try: - from acb_graph import get_session # noqa: PLC0415 - from sqlalchemy import text # noqa: PLC0415 + from acb_graph import get_session + from sqlalchemy import text + with get_session() as s: - result = s.execute(text(q)) - rows = result.fetchmany(20) + result = s.execute(text(_SEARCH_SQL), params) + rows = result.fetchmany(lim) columns = list(result.keys()) - except Exception as exc: # noqa: BLE001 + except Exception as exc: return f"query_history failed: {exc}" if not rows: return "[]" - # Format as JSON with content truncation. output: list[dict] = [] for row in rows: entry: dict = {} for i, col in enumerate(columns): val = row[i] - if isinstance(val, str) and len(val) > 500: - val = val[:500] + "..." - # Convert non-serializable types. + if isinstance(val, str) and len(val) > _CONTENT_CAP: + val = val[:_CONTENT_CAP] + "..." try: _json.dumps(val) except (TypeError, ValueError): diff --git a/packages/acb_skills/acb_skills/integrations.py b/packages/acb_skills/acb_skills/integrations.py index cc42e3dda..a1e558e04 100644 --- a/packages/acb_skills/acb_skills/integrations.py +++ b/packages/acb_skills/acb_skills/integrations.py @@ -33,7 +33,8 @@ from __future__ import annotations import os -from collections.abc import Callable +from collections.abc import Callable, Mapping +from contextvars import ContextVar, Token from typing import Any from acb_common import get_logger @@ -212,10 +213,10 @@ def _google_sheets(s: Any) -> dict[str, Any]: # Canonical credential-dict-field → env-var mapping per service. Single source -# of truth shared by the executor's run-scoped env injection -# (``_inject_integrations_to_env``) and the coding skill's script-subprocess -# env (``code_tools._script_env``): whatever the executor exports for a run is -# exactly what a declared integration's scripts may read. +# of truth shared by the executor's run-scoped credential binding +# (``bind_run_credentials``) and the coding skill's script-subprocess env +# (``code_tools._script_env``): whatever a run binds is exactly what a declared +# integration's scripts may read. FIELD_TO_ENV: dict[str, list[tuple[str, str]]] = { "zoho-crm": [ ("client_id", "ZOHO_CLIENT_ID"), @@ -256,6 +257,104 @@ def env_var_names(services: list[str] | tuple[str, ...]) -> set[str]: return names +# --------------------------------------------------------------------------- +# Per-run credential binding (MT-0a) — contextvar, NOT os.environ +# +# `saas_multitenancy.md` §6.1 / MT-0a. The executor used to export a run's +# resolved credentials into the gateway's process-global ``os.environ`` and +# restore them at teardown. That removed *permanent accumulation* but could not +# remove *concurrent* exposure, and the code said so itself: "os.environ is +# process-global, so under concurrent in-process runs the scoping is +# best-effort — two overlapping runs still share the env for the overlap +# window." +# +# Under one tenant that is a within-org concern. Under two it is a credential +# leak: tenant A's ClickUp token is readable by tenant B's concurrently-running +# agent — and agents run model-generated tool calls over content ingested from +# email and WhatsApp, which is precisely the code that must be assumed hostile. +# +# A ContextVar is per-asyncio-task and is copied into tasks created from the +# binding context, so two overlapping runs each see only their own credentials +# with no window at all. That is the whole change. +# --------------------------------------------------------------------------- + +#: ``None`` default rather than ``{}`` — a mutable ContextVar default is shared +#: by every context that never sets one (ruff B039), which is precisely the +#: process-global sharing this whole change exists to remove. Readers normalise +#: it through :func:`run_credentials`. +_RUN_CREDENTIALS: ContextVar[Mapping[str, str] | None] = ContextVar( + "acb_run_credentials", default=None, +) + + +def bind_run_credentials(integrations: dict[str, Any]) -> Token[Mapping[str, str] | None]: + """Bind *integrations*' credentials to the current run's async context. + + Returns a token the caller **must** pass to :func:`release_run_credentials` + at the run's teardown. Empty values are skipped, so an unconfigured optional + integration binds nothing rather than an empty string a caller might treat + as present. + + Unlike the ``os.environ`` export this replaces, nothing here is visible to + any other task: a concurrent run in the same process binds its own value and + the two never observe each other. + """ + env: dict[str, str] = {} + for service, creds in (integrations or {}).items(): + if not isinstance(creds, dict): + continue + for field, env_var in FIELD_TO_ENV.get(service, []): + val = creds.get(field, "") + if val: + env[env_var] = str(val) + return _RUN_CREDENTIALS.set(env) + + +def release_run_credentials(token: Token[Mapping[str, str] | None] | None) -> None: + """Undo :func:`bind_run_credentials`. Never raises. + + ``ContextVar.reset`` rejects a token created in a *different* Context, which + a teardown running on another task would hit. Falling back to an explicit + empty bind keeps the failure closed — the run's credentials are gone either + way — rather than leaving them readable because the reset raised. + """ + if token is None: + return + try: + _RUN_CREDENTIALS.reset(token) + except (ValueError, RuntimeError): + _RUN_CREDENTIALS.set(None) + + +def run_credentials() -> Mapping[str, str]: + """This run's bound credentials, keyed by canonical env-var name. + + Empty mapping when nothing is bound — callers never see the ``None`` the + ContextVar stores as its (deliberately immutable) default. + """ + return _RUN_CREDENTIALS.get() or {} + + +def credential(name: str, default: str = "") -> str: + """Read one credential by canonical env-var name. + + **This is what in-process skills must call instead of ``os.getenv``.** + + Precedence is deliberate and unchanged from the behaviour this replaces: + an operator-provided value in the process environment wins, because it is a + deployment-wide setting the operator chose and the old code explicitly did + not overwrite it ("Gateway .env still wins"). Only then does the run's own + bound credential apply. + + ⚠️ **Honest limit, and it is the reason MT-0d exists.** An operator-provided + var IS still process-global and therefore still shared across tenants. MT-0a + scopes the *run-resolved* credentials; making the operator's own store + per-tenant is MT-0d (``provider_keys`` keyed ``(organization_id, provider)``). + Do not read this function as making the process environment tenant-safe. + """ + return os.environ.get(name) or run_credentials().get(name, "") or default + + # Master registry: service-name → resolver _REGISTRY: dict[str, Any] = { "zoho-crm": _zoho_crm, diff --git a/packages/acb_skills/acb_skills/skill_families.py b/packages/acb_skills/acb_skills/skill_families.py index 6fb4c89cf..43275eae0 100644 --- a/packages/acb_skills/acb_skills/skill_families.py +++ b/packages/acb_skills/acb_skills/skill_families.py @@ -123,12 +123,12 @@ "history": { "label": "Conversation history", "summary": ( - "Recall what was discussed in earlier sessions with a SELECT-only " - "SQL query over the chat history database." + "Recall what was discussed in earlier sessions by searching the " + "chat history." ), "description": ( - "SELECT-only SQL over the chat history database — recall what " - "was discussed in prior sessions." + "Search past conversations by text, thread, agent or recency — " + "recall what was discussed in prior sessions." ), "tools": ("query_history",), "core": False, diff --git a/packages/acb_skills/acb_skills/web_tools.py b/packages/acb_skills/acb_skills/web_tools.py index 6eea6b58e..051595b0a 100644 --- a/packages/acb_skills/acb_skills/web_tools.py +++ b/packages/acb_skills/acb_skills/web_tools.py @@ -118,9 +118,13 @@ async def _serpapi_search(query: str, max_results: int) -> list[dict] | str: Returns a ddgs-shaped list of {title, href, body} dicts on success, or an error string ("" = no key configured, so nothing to report). """ - import os + # MT-0a: the run's bound credential, not the process env — this helper is + # called in-process from a tool, where `os.environ` would expose whatever a + # concurrent run exported (`saas_multitenancy.md` §6.1). An operator-set + # value still wins; the Settings fallback below is unchanged. + from acb_skills.integrations import credential - key = os.environ.get("SERPAPI_API_KEY", "") + key = credential("SERPAPI_API_KEY") if not key: try: from acb_common import get_settings diff --git a/scripts/gen_tenant_migration.py b/scripts/gen_tenant_migration.py new file mode 100644 index 000000000..786ddce03 --- /dev/null +++ b/scripts/gen_tenant_migration.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Generate the MT-1b tenancy migration — org_id + FORCE RLS on every table. + +Spec: ``ai-company-brain/specs/saas_multitenancy.md`` §1.3 / MT-1b · +shapes in ``saas_multitenancy_implementation.md`` §1 · board WS-29 · D15. + +WHY A GENERATOR AND NOT A HAND-WRITTEN MIGRATION +------------------------------------------------ +143 tables. Hand-writing that is 143 chances to omit ``FORCE``, or ``WITH +CHECK``, or the ``, true`` missing-ok flag — and each omission is silent. A +generator makes the *template* the reviewable artifact and the per-table +expansion mechanical. + +WHY THE OUTPUT IS NOT A NUMBERED MIGRATION +------------------------------------------ +Read ``scripts/apply_migrations.sh`` before changing this. That runner exists in +its current shape because of a **14h44m production outage**: a hung LLM call held +a session open, the runner asked for ACCESS EXCLUSIVE behind it, and because +Postgres's lock queue is FIFO every later reader queued behind the *waiting* +ALTER. Sending mail stopped. + +MT-1b is precisely that shape of change, 143 times over: + +* ``ADD COLUMN ... NOT NULL`` and ``SET NOT NULL`` take **ACCESS EXCLUSIVE** and + scan the whole table. On ``email_messages`` with real mail in it, that is not + instant. +* The backfill ``UPDATE`` rewrites every row. +* A single transaction wrapping all of it holds every lock until the end. + +So this script writes to ``infra/postgres/generated/`` — **outside the numbered +sequence the deploy replays.** Promoting it into the sequence is a deliberate +human act, taken against a database, in a window. It is not something that +should happen because a file landed on main. + +PHASING (why the output is four files, not one) +----------------------------------------------- +Each phase is separately applicable and separately abortable: + + 1. ``add_columns`` — nullable ADD COLUMN, no scan, no lock of consequence + 2. ``backfill`` — batched UPDATE; re-runnable; the slow part + 3. ``constraints`` — SET NOT NULL + FK + index; the ACCESS EXCLUSIVE phase + 4. ``policies`` — ENABLE + FORCE RLS + the policy; instant, and the + moment isolation becomes real + +⚠️ **Phase 4 is a cliff.** The instant it applies, every connection that has not +bound ``app.tenant_id`` reads **zero rows** — that is the fail-closed property +(§0.1) working as designed, and it means MT-1c must be deployed and verified +FIRST or the product goes dark. The ordering is not a preference. + +Usage:: + + uv run python scripts/gen_tenant_migration.py # write the four files + uv run python scripts/gen_tenant_migration.py --dry-run # print a summary +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +_MIGRATIONS = _REPO / "infra" / "postgres" +_OUT = _MIGRATIONS / "generated" + +#: Tables that are cross-tenant BY DESIGN and must never carry a policy. +#: **This list is the security review.** Adding a name here exempts a table from +#: tenant isolation, so every entry carries its reason and a reviewer is expected +#: to challenge it. +EXEMPT: dict[str, str] = { + # ── Control plane (§1.5): must be readable ACROSS tenants ────────────── + "organization": "the tenant list itself", + "tenant_placement": "control plane — which data plane serves whom", + "user_identity": "control plane — one row per human, global by design", + "org_membership": "control plane — the tenant-scoped half; org_id is its PK", + # ── Catalogs: identical for every tenant, no customer data ───────────── + "feature_catalog": "a catalog of product surfaces, not tenant data", + "schema_migrations": "migration bookkeeping", + # ── Already tenant-keyed by an earlier migration ─────────────────────── + "provider_keys": "keyed (organization_id, provider) by MT-0d / 158", + "model_config": "keyed (organization_id, key) by MT-0d / 158", + "mcp_servers": "keyed (organization_id, name) by MT-0d / 158", + "org_role": "carries organization_id since 130", + "org_group": "carries organization_id since 138", + # ── Vendored schemas we do not own ───────────────────────────────────── + "_prisma_migrations": "LiteLLM's own schema", +} + +#: Tables whose ``organization_id`` is reachable only through a parent. Listed so +#: a reviewer sees they were CONSIDERED, not missed — each still gets its own +#: column (denormalised on purpose: a policy that has to JOIN to find the tenant +#: is a policy that is slow on every read and wrong under a missing parent). +_DENORMALISE_NOTE = ( + "child rows carry their own organization_id rather than joining to a parent: " + "an RLS policy runs on EVERY row of EVERY query, and a join in USING() is " + "both a performance cliff and a correctness hole when the parent is gone" +) + +_CREATE_RE = re.compile( + r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?" + r"(?:public\.)?[\"']?([a-z_][a-z0-9_]*)[\"']?", + re.IGNORECASE, +) + + +def discover_tables() -> list[str]: + """Every table the numbered migrations create, in name order.""" + names: set[str] = set() + for path in sorted(_MIGRATIONS.glob("[0-9]*_*.sql")): + for match in _CREATE_RE.finditer(path.read_text(encoding="utf-8")): + names.add(match.group(1).lower()) + return sorted(names) + + +def _header(phase: str, why: str, tables: int) -> str: + return f"""-- ============================================================================ +-- MT-1b · phase {phase} — GENERATED, DO NOT EDIT BY HAND +-- ============================================================================ +-- Regenerate with: uv run python scripts/gen_tenant_migration.py +-- Spec: ai-company-brain/specs/saas_multitenancy.md §1.3 · MT-1b · WS-29 · D15 +-- +-- {why} +-- +-- Tables in this phase: {tables} +-- +-- ⚠️ 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 +-- outage that makes that non-negotiable. +-- ============================================================================ + +""" + + +def gen_add_columns(tables: list[str]) -> str: + out = [_header("1/4 add_columns", + "Nullable ADD COLUMN. No table scan, no lock of consequence. " + "Safe to apply on a live system.", len(tables))] + for t in tables: + out.append( + f"ALTER TABLE {t}\n" + f" ADD COLUMN IF NOT EXISTS organization_id UUID\n" + f" DEFAULT current_setting('app.tenant_id', true)::uuid;\n" + ) + return "\n".join(out) + + +def gen_backfill(tables: list[str]) -> str: + out = [_header("2/4 backfill", + "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.", len(tables))] + out.append( + "-- The operator's own organization owns every pre-existing row: this box\n" + "-- served exactly one tenant before MT-1b.\n" + ) + for t in tables: + out.append( + f"UPDATE {t} SET organization_id = " + f"(SELECT id FROM organization WHERE slug = 'default')\n" + f" WHERE organization_id IS NULL;\n" + ) + return "\n".join(out) + + +def gen_constraints(tables: list[str]) -> str: + out = [_header("3/4 constraints", + "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).", len(tables))] + for t in tables: + out.append( + f"-- {t}\n" + f"DO $$\nBEGIN\n" + f" IF EXISTS (SELECT 1 FROM {t} WHERE organization_id IS NULL) THEN\n" + f" RAISE EXCEPTION 'MT-1b: {t} still has unowned rows — " + f"run phase 2 (backfill) to completion first';\n" + f" END IF;\n" + f"END $$;\n" + f"ALTER TABLE {t} ALTER COLUMN organization_id SET NOT NULL;\n" + f"ALTER TABLE {t} ADD CONSTRAINT {t}_org_fk\n" + f" FOREIGN KEY (organization_id) REFERENCES organization(id) " + f"ON DELETE CASCADE;\n" + f"CREATE INDEX IF NOT EXISTS {t}_org_idx ON {t} (organization_id);\n" + ) + return "\n".join(out) + + +def gen_policies(tables: list[str]) -> str: + out = [_header("4/4 policies", + "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.", + len(tables))] + out.append( + "-- Four clauses, each load-bearing (saas_multitenancy_implementation.md §1.1):\n" + "-- ENABLE turns the policy on for ordinary roles\n" + "-- FORCE applies it to the table OWNER too — without this the\n" + "-- owner silently reads every tenant\n" + "-- USING filters what a query can SEE\n" + "-- WITH CHECK constrains what it can WRITE. Without it a tenant can\n" + "-- INSERT a row stamped with another tenant's id.\n" + "-- , true makes an unset GUC return NULL (-> no rows) instead of\n" + "-- RAISING, so an unconverted path fails closed and quiet\n" + "-- rather than 500-ing everywhere at once.\n" + ) + for t in tables: + out.append( + f"ALTER TABLE {t} ENABLE ROW LEVEL SECURITY;\n" + f"ALTER TABLE {t} FORCE ROW LEVEL SECURITY;\n" + f"DROP POLICY IF EXISTS {t}_tenant_isolation ON {t};\n" + f"CREATE POLICY {t}_tenant_isolation ON {t}\n" + f" USING (organization_id = current_setting('app.tenant_id', true)::uuid)\n" + f" WITH CHECK (organization_id = current_setting('app.tenant_id', true)::uuid);\n" + ) + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--dry-run", action="store_true", + help="print the plan without writing files") + args = ap.parse_args() + + all_tables = discover_tables() + scoped = [t for t in all_tables if t not in EXEMPT] + exempted = [t for t in all_tables if t in EXEMPT] + + print(f"discovered {len(all_tables)} tables in infra/postgres/[0-9]*.sql") + print(f" tenant-scoped : {len(scoped)}") + print(f" exempt : {len(exempted)}") + for t in exempted: + print(f" {t:<24} {EXEMPT[t]}") + unknown = sorted(set(EXEMPT) - set(all_tables)) + if unknown: + print("\n ⚠️ exempt names that match no discovered table " + "(stale entry, or the table moved):") + for t in unknown: + print(f" {t}") + + if args.dry_run: + print(f"\n(dry run — nothing written)\n{_DENORMALISE_NOTE}") + return 0 + + _OUT.mkdir(parents=True, exist_ok=True) + phases = { + "01_add_columns.sql": gen_add_columns(scoped), + "02_backfill.sql": gen_backfill(scoped), + "03_constraints.sql": gen_constraints(scoped), + "04_policies.sql": gen_policies(scoped), + } + for name, body in phases.items(): + (_OUT / name).write_text(body, encoding="utf-8") + print(f"wrote {(_OUT / name).relative_to(_REPO)}") + + print("\n⚠️ These are NOT numbered migrations and will not be replayed by " + "apply_migrations.sh. Apply phases 1-4 IN ORDER, against a scratch " + "database first. Phase 4 requires MT-1c deployed and verified.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index f55774705..e7b6fdb92 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,20 @@ """Shared fixtures for the tests/ tree (CI runs `pytest tests/unit/`).""" from __future__ import annotations +import os + import pytest +# Snapshot DATABASE_URL before any test module imports. `import litellm` +# (reached through acb_llm by several test modules) calls load_dotenv() at +# import time, which copies a dev machine's .env DATABASE_URL into os.environ +# mid-collection — and the DB-gated tests in test_tenant_coverage.py would then +# run against whatever that value names instead of skipping. Those gates must +# answer to the environment pytest was LAUNCHED with, not to whichever module +# happened to import first. conftest.py imports before every test module, so +# this line runs ahead of any litellm import. +os.environ.setdefault("_ACB_DATABASE_URL_AT_LAUNCH", os.environ.get("DATABASE_URL", "")) + @pytest.fixture(autouse=True) def _isolate_write_artifact_context(): diff --git a/tests/unit/test_db_engine_seam.py b/tests/unit/test_db_engine_seam.py index e8ab846f9..21d402df5 100644 --- a/tests/unit/test_db_engine_seam.py +++ b/tests/unit/test_db_engine_seam.py @@ -10,11 +10,36 @@ This is the ratchet. It is a source-level check on purpose: the failure mode it guards is a *new* engine being introduced by a new app package, which no runtime assertion sees until that package is under load in production. + +MT-1c — the sync half (``saas_multitenancy.md`` §0.1) +----------------------------------------------------- +The connection inventory in §0.1 found **eight** paths that open a database +connection, and named this file as the reason two of them went unnoticed for +months: *"the seam test only inspects ``create_async_engine``, so this file is +unguarded by it"* (path 4, ``acb_graph/db.py:32``, the **sync** +``create_engine``). Under the pooled tenant boundary (D15) an unguarded +connection path is not a pool-budget problem any more — it is a path that can +open a connection with no ``app.tenant_id`` bound, and RLS decides what a +tenant sees at the connection, not at the query. + +So the ratchet now covers both constructors, each with its own allow-list. +§0.1's acceptance criterion 2 is exactly this test. Criterion 3 — raw +``psycopg.connect``, paths 5-7 — is the companion ratchet in +``test_psycopg_seam.py``; it is a separate file because it has a separate +allow-list with separate reasons, and merging them would blur which discipline +an entry was admitted under. + +Path 8 (``acb_memory/mem0_client.py``) is caught by **neither** ratchet by +construction: it opens no connection itself, it hands a conninfo string to +Mem0's own pgvector client. §0.1 criterion 4 calls that out as the genuinely +awkward one and owns it separately. A source-level ratchet cannot see a +connection opened inside a third-party library. """ from __future__ import annotations import ast +from functools import cache from pathlib import Path import pytest @@ -39,6 +64,20 @@ "separate process; per-run engines, disposed when the run ends", } +#: Every file allowed to call the **sync** ``create_engine``, and why. +#: +#: Kept separate from ``_ALLOWED`` above rather than merged into it: the two +#: calls are admitted on different grounds. An entry above answers "why can this +#: not use the shared async pool?"; an entry here answers "why does this need a +#: synchronous engine at all?", and — since MT-1c — "which tenant does it bind?". +#: One list would let an entry inherit a reason it was never granted. +_ALLOWED_SYNC: dict[str, str] = { + "packages/acb_graph/acb_graph/db.py": + "the entity-graph sync engine — connection path 4 in " + "saas_multitenancy.md §0.1; carries tenant data and MUST bind a tenant " + "under MT-1c", +} + def _python_files() -> list[Path]: roots = [_REPO / "apps", _REPO / "packages"] @@ -51,28 +90,49 @@ def _python_files() -> list[Path]: return out -def _calls_create_async_engine(path: Path) -> bool: - """True if the file CALLS ``create_async_engine``. +@cache +def _called_names(path: Path) -> frozenset[str]: + """Every name the file CALLS, as bare identifiers. - Parsed rather than grepped: every one of these modules mentions the name in - prose explaining that it does not call it, and a substring match would read + Parsed rather than grepped: several of these modules mention the engine + constructors in prose explaining that they do not call them + (``acb_skills/history_tools.py`` names the sync ``create_engine`` in its + module docstring while calling nothing), and a substring match would read the documentation as a violation. + + Attribute calls collapse to their final segment, so ``sa.create_engine(...)`` + counts the same as an imported ``create_engine(...)`` — the ratchet is about + a connection being opened, not about how the symbol was spelled. + + Cached because the tree is walked once per constructor and the file set is + the same both times. """ # utf-8-sig: at least one module in the tree carries a BOM, and a leading #  is a syntax error to ast.parse. tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + names: set[str] = set() for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func - name = ( - func.id if isinstance(func, ast.Name) - else func.attr if isinstance(func, ast.Attribute) - else None - ) - if name == "create_async_engine": - return True - return False + if isinstance(func, ast.Name): + names.add(func.id) + elif isinstance(func, ast.Attribute): + names.add(func.attr) + return frozenset(names) + + +def _calls_create_async_engine(path: Path) -> bool: + return "create_async_engine" in _called_names(path) + + +def _calls_create_engine(path: Path) -> bool: + """True if the file CALLS the **sync** ``create_engine``. + + Exact-match on the identifier, so ``create_async_engine`` — a different + name, checked by its own allow-list above — never lands here. + """ + return "create_engine" in _called_names(path) def test_no_new_async_engines() -> None: @@ -106,6 +166,46 @@ def test_allowlist_has_no_stale_entries() -> None: assert not stale, f"Allowlist entries that no longer create an engine: {stale}" +def test_no_new_sync_engines() -> None: + """MT-1c, ``saas_multitenancy.md`` §0.1 criterion 2. + + A sync ``create_engine`` is as much a connection path as an async one, and + RLS binds ``app.tenant_id`` per connection. A new unlisted one is a path + that reaches tenant rows with nothing bound. + """ + offenders = sorted( + str(p.relative_to(_REPO)).replace("\\", "/") + for p in _python_files() + if _calls_create_engine(p) + ) + unexpected = [p for p in offenders if p not in _ALLOWED_SYNC] + assert not unexpected, ( + "New sync create_engine() call site(s):\n " + + "\n ".join(unexpected) + + "\n\nPrefer acb_common.db (get_db / get_session_factory) — one engine " + "and one pool per process. If this engine genuinely must be " + "synchronous, add it to _ALLOWED_SYNC in this test with the reason, " + "and state how it binds a tenant (saas_multitenancy.md §0.1)." + ) + + +def test_sync_allowlist_has_no_stale_entries() -> None: + """Same discipline as the async list: an entry that stopped calling leaves. + + Stated separately rather than folded into the async check so a stale sync + entry names itself in the failure instead of arriving in a mixed list. + """ + offenders = { + str(p.relative_to(_REPO)).replace("\\", "/") + for p in _python_files() + if _calls_create_engine(p) + } + stale = sorted(set(_ALLOWED_SYNC) - offenders) + assert not stale, ( + f"Sync allowlist entries that no longer create an engine: {stale}" + ) + + def test_gateway_makes_no_engine_of_its_own() -> None: """The gateway process holds exactly one pool. @@ -113,12 +213,17 @@ def test_gateway_makes_no_engine_of_its_own() -> None: gateway is where the twelve pools were, and it is the process where a second one is most expensive — it also runs ``acb_auth.access``, which resolves permissions from Postgres on the request path. + + Covers the sync constructor too since MT-1c: a synchronous engine in the + gateway is a second pool AND a second connection path to bind, and it would + block the event loop besides. """ gateway = _REPO / "apps" / "services" / "gateway" offenders = sorted( str(p.relative_to(_REPO)).replace("\\", "/") for p in gateway.rglob("*.py") - if "__pycache__" not in p.parts and _calls_create_async_engine(p) + if "__pycache__" not in p.parts + and (_calls_create_async_engine(p) or _calls_create_engine(p)) ) assert offenders == [] diff --git a/tests/unit/test_integration_env_scoping.py b/tests/unit/test_integration_env_scoping.py index 021a84557..34393cc6e 100644 --- a/tests/unit/test_integration_env_scoping.py +++ b/tests/unit/test_integration_env_scoping.py @@ -1,30 +1,44 @@ -"""Unit tests for B6 Phase-5 Tier 0 — per-run integration credential scoping. - -The executor materialises resolved integration credentials into ``os.environ`` -so subprocess skill scripts can ``os.getenv`` them. Previously it wrote each -var once and NEVER cleared it, so every secret ever used accumulated in the -shared gateway process env for its lifetime — any later/concurrent-idle agent -(incl. a prompt-injected one) could read another integration's secret regardless -of its own ``config.json`` scope. - -Tier 0 makes this scoped: ``_inject_integrations_to_env`` returns a restore -token (the prior value of every var it SET), and ``_restore_integration_env`` -puts each var back at the run teardown. These tests lock that contract: - -- only the run's own integrations are exported (scope); -- an operator-provided (already-present) var is neither overwritten nor deleted; -- teardown restores unset→deleted and pre-existing→prior value (no accumulation, - no clobbering); -- teardown is idempotent / null-token safe and never raises. - -See ``ai-company-brain/specs/permissions_sandbox_b6.md`` (Phase 5, Tier 0). +"""MT-0a — per-run integration credential scoping. + +**History, because it explains the shape of these tests.** The executor used to +materialise a run's resolved credentials into the gateway's process-global +``os.environ`` so subprocess skill scripts could ``os.getenv`` them. B6 Phase-5 +Tier 0 made that *scoped* — a restore token put every var back at teardown — which +removed the permanent accumulation but could not remove concurrent exposure, and +the code said so itself: + + "os.environ is process-global, so under *concurrent* in-process runs the + scoping is best-effort — two overlapping runs still share the env for the + overlap window." + +Under one tenant that is a within-org concern. Under two it is a **credential +leak**: tenant A's ClickUp token is readable by tenant B's concurrently-running +agent, and agents execute model-generated tool calls over content ingested from +email and WhatsApp. `saas_multitenancy.md` §6.1 / MT-0a therefore replaces the +bridge with a ``ContextVar``, which is per-task and cannot overlap. + +What these tests lock: + +- **the overlap window is gone** — two interleaved runs never observe each + other's credentials (``test_concurrent_runs_cannot_observe_each_other``; this + is the one that was verified RED against the os.environ implementation); +- the executor writes **no** credential into ``os.environ`` at all; +- teardown releases, is idempotent, and never raises; +- an operator-provided value still wins (unchanged precedence); +- only the run's own integrations are visible (scope). + +See ``ai-company-brain/specs/saas_multitenancy.md`` §6.1 and MT-0a, and +``saas_multitenancy_implementation.md`` §6. """ from __future__ import annotations +import asyncio import os +from pathlib import Path import orchestrator.executor as ex - +import pytest +from acb_skills.integrations import credential, run_credentials # -------------------------------------------------------------------------- # Sample resolved-integration dicts (shape produced by build_integrations). @@ -34,118 +48,180 @@ def _clean(monkeypatch, *env_vars: str) -> None: - """Ensure the named vars start absent so we test the unset→set→delete path.""" + """Ensure the named vars start absent, so a leak cannot hide behind one.""" for v in env_vars: monkeypatch.delenv(v, raising=False) -# -------------------------------------------------------------------------- -# Scope: only this run's integrations are exported. -# -------------------------------------------------------------------------- -def test_injects_only_this_runs_integrations(monkeypatch) -> None: - _clean(monkeypatch, "CLICKUP_API_TOKEN", "CLICKUP_WORKSPACE_ID", "APIFY_API_TOKEN") +# ========================================================================== +# THE POINT OF THE TICKET — concurrent runs are isolated. +# ========================================================================== +def test_concurrent_runs_cannot_observe_each_other(monkeypatch) -> None: + """Two overlapping runs must never see each other's credentials. - token = ex._inject_integrations_to_env(_CLICKUP) - - assert os.environ["CLICKUP_API_TOKEN"] == "clk-secret-123" - assert os.environ["CLICKUP_WORKSPACE_ID"] == "ws-9" - # An integration NOT in this run's dict is never exported. - assert "APIFY_API_TOKEN" not in os.environ - # Token records both vars we set, each with prior value None (were unset). - assert token == {"CLICKUP_API_TOKEN": None, "CLICKUP_WORKSPACE_ID": None} - - ex._restore_integration_env(token) + ⚠️ **This test was verified RED against the os.environ implementation** it + replaced: under that code run B's ``os.environ`` write was visible to run A + for the whole overlap window, and A read B's token. It is the reason MT-0a + exists — do not weaken it into a sequential check, which the old code passed. + The interleaving is deliberate and is what the old implementation failed: + A binds → B binds → A reads → B reads → A releases → B releases + """ + _clean(monkeypatch, "CLICKUP_API_TOKEN", "CLICKUP_WORKSPACE_ID", "APIFY_API_TOKEN") -def test_restore_deletes_vars_that_were_unset_before(monkeypatch) -> None: + seen: dict[str, dict[str, str]] = {} + gate_a, gate_b = asyncio.Event(), asyncio.Event() + + async def run_a() -> None: + tok = ex._bind_run_credentials(_CLICKUP) + gate_a.set() # A is bound + await gate_b.wait() # …wait for B to bind on top + seen["a"] = { + "own": credential("CLICKUP_API_TOKEN"), + "other": credential("APIFY_API_TOKEN"), + } + ex._release_run_credentials(tok) + + async def run_b() -> None: + await gate_a.wait() # bind while A is still live + tok = ex._bind_run_credentials(_APIFY) + gate_b.set() + seen["b"] = { + "own": credential("APIFY_API_TOKEN"), + "other": credential("CLICKUP_API_TOKEN"), + } + ex._release_run_credentials(tok) + + async def main() -> None: + # Separate tasks — each gets its own copy of the context, which is the + # entire mechanism under test. + await asyncio.gather(run_a(), run_b()) + + asyncio.run(main()) + + assert seen["a"]["own"] == "clk-secret-123" + assert seen["b"]["own"] == "apify-secret-xyz" + # The assertions that were red before MT-0a: + assert seen["a"]["other"] == "", "run A could read run B's credential" + assert seen["b"]["other"] == "", "run B could read run A's credential" + + +def test_a_sibling_task_started_before_the_bind_sees_nothing(monkeypatch) -> None: + """A task that is not a child of the binding context must see no credential. + + Guards the property that makes the ContextVar sound: credentials propagate + DOWN into tasks created from the bound context, never sideways. + """ + _clean(monkeypatch, "CLICKUP_API_TOKEN") + observed: list[str] = [] + released = asyncio.Event() + + async def bystander() -> None: + await released.wait() + observed.append(credential("CLICKUP_API_TOKEN")) + + async def main() -> None: + task = asyncio.create_task(bystander()) # created BEFORE the bind + tok = ex._bind_run_credentials(_CLICKUP) + released.set() + await task + ex._release_run_credentials(tok) + + asyncio.run(main()) + assert observed == [""] + + +# ========================================================================== +# The process environment is no longer the bridge. +# ========================================================================== +def test_binding_writes_nothing_to_os_environ(monkeypatch) -> None: _clean(monkeypatch, "CLICKUP_API_TOKEN", "CLICKUP_WORKSPACE_ID") - token = ex._inject_integrations_to_env(_CLICKUP) - assert "CLICKUP_API_TOKEN" in os.environ # set during the "run" - - ex._restore_integration_env(token) - - # Teardown removes them — no accumulation into the shared env. - assert "CLICKUP_API_TOKEN" not in os.environ - assert "CLICKUP_WORKSPACE_ID" not in os.environ - - -def test_no_accumulation_across_two_sequential_runs(monkeypatch) -> None: - """Run A's secret must be gone before Run B (different integration) starts.""" + tok = ex._bind_run_credentials(_CLICKUP) + try: + assert "CLICKUP_API_TOKEN" not in os.environ + assert "CLICKUP_WORKSPACE_ID" not in os.environ + # …but the run itself can read them. + assert credential("CLICKUP_API_TOKEN") == "clk-secret-123" + finally: + ex._release_run_credentials(tok) + + +def test_executor_never_assigns_a_credential_into_os_environ() -> None: + """MT-0a done-when 3 — a grep assertion, so a later PR cannot reintroduce it. + + Narrow on purpose: the executor legitimately sets non-credential vars such as + ``ACB_AGENT_USER_EMAIL``. What must never come back is a write whose value + comes from the resolved-integration mapping. + """ + src = Path(ex.__file__).read_text(encoding="utf-8") + assert "FIELD_TO_ENV" not in src, ( + "executor.py must not map credential fields to env vars itself — " + "binding belongs to acb_skills.integrations.bind_run_credentials (MT-0a)" + ) + for banned in ("os.environ[env_var]", "os.environ[var]"): + assert banned not in src, f"executor.py reintroduced a credential env write: {banned}" + + +# ========================================================================== +# Scope, precedence, teardown. +# ========================================================================== +def test_binds_only_this_runs_integrations(monkeypatch) -> None: _clean(monkeypatch, "CLICKUP_API_TOKEN", "CLICKUP_WORKSPACE_ID", "APIFY_API_TOKEN") - # Run A: clickup. - tok_a = ex._inject_integrations_to_env(_CLICKUP) - assert os.environ.get("CLICKUP_API_TOKEN") == "clk-secret-123" - ex._restore_integration_env(tok_a) + tok = ex._bind_run_credentials(_CLICKUP) + try: + assert credential("CLICKUP_API_TOKEN") == "clk-secret-123" + assert credential("CLICKUP_WORKSPACE_ID") == "ws-9" + assert credential("APIFY_API_TOKEN") == "" # not this run's + finally: + ex._release_run_credentials(tok) - # Run B: apify — must NOT be able to read run A's leftover clickup secret. - tok_b = ex._inject_integrations_to_env(_APIFY) - assert "CLICKUP_API_TOKEN" not in os.environ, "run A's secret leaked into run B" - assert os.environ.get("APIFY_API_TOKEN") == "apify-secret-xyz" - ex._restore_integration_env(tok_b) - assert "APIFY_API_TOKEN" not in os.environ +def test_operator_provided_value_still_wins(monkeypatch) -> None: + """Unchanged precedence: an operator's .env value beats the run's. + It is deployment-wide by the operator's choice, and the implementation this + replaced explicitly did not overwrite it. Making the operator's own store + per-tenant is MT-0d, not this ticket. + """ + monkeypatch.setenv("CLICKUP_API_TOKEN", "operator-value") -# -------------------------------------------------------------------------- -# Operator .env wins: a pre-existing var is never touched. -# -------------------------------------------------------------------------- -def test_preexisting_env_var_not_overwritten_and_not_recorded(monkeypatch) -> None: - # Operator provided the value via gateway .env. - monkeypatch.setenv("CLICKUP_API_TOKEN", "operator-provided-value") - _clean(monkeypatch, "CLICKUP_WORKSPACE_ID") + tok = ex._bind_run_credentials(_CLICKUP) + try: + assert credential("CLICKUP_API_TOKEN") == "operator-value" + finally: + ex._release_run_credentials(tok) + assert os.environ["CLICKUP_API_TOKEN"] == "operator-value" # untouched - token = ex._inject_integrations_to_env(_CLICKUP) - # We did NOT overwrite the operator's value... - assert os.environ["CLICKUP_API_TOKEN"] == "operator-provided-value" - # ...and we did NOT record it in the token (so teardown won't delete it). - assert "CLICKUP_API_TOKEN" not in token - # We DID export the one it didn't provide. - assert token == {"CLICKUP_WORKSPACE_ID": None} +def test_release_clears_the_binding(monkeypatch) -> None: + _clean(monkeypatch, "CLICKUP_API_TOKEN") - ex._restore_integration_env(token) + tok = ex._bind_run_credentials(_CLICKUP) + assert credential("CLICKUP_API_TOKEN") == "clk-secret-123" + ex._release_run_credentials(tok) + assert credential("CLICKUP_API_TOKEN") == "" + assert run_credentials() == {} - # The operator's value survives teardown; ours is cleaned up. - assert os.environ["CLICKUP_API_TOKEN"] == "operator-provided-value" - assert "CLICKUP_WORKSPACE_ID" not in os.environ +def test_release_is_null_safe_and_never_raises() -> None: + ex._release_run_credentials(None) # must not raise + tok = ex._bind_run_credentials(_CLICKUP) + ex._release_run_credentials(tok) + ex._release_run_credentials(tok) # double release — must not raise -def test_restore_puts_back_a_prior_value_never_deletes_operator_var(monkeypatch) -> None: - # Simulate a var that WAS present with a prior value the injector left alone. - monkeypatch.setenv("APIFY_API_TOKEN", "prior-operator-token") - token = ex._inject_integrations_to_env(_APIFY) - # Untouched (operator wins) and unrecorded. - assert os.environ["APIFY_API_TOKEN"] == "prior-operator-token" - assert token == {} - - ex._restore_integration_env(token) - assert os.environ["APIFY_API_TOKEN"] == "prior-operator-token" - - -# -------------------------------------------------------------------------- -# Robustness: empty / null tokens, malformed dicts, blank creds. -# -------------------------------------------------------------------------- -def test_restore_none_and_empty_token_is_safe() -> None: - ex._restore_integration_env(None) # must not raise - ex._restore_integration_env({}) # must not raise - - -def test_blank_credential_values_are_skipped(monkeypatch) -> None: - _clean(monkeypatch, "APIFY_API_TOKEN") - token = ex._inject_integrations_to_env({"apify": {"api_token": ""}}) - assert token == {} - assert "APIFY_API_TOKEN" not in os.environ - - -def test_non_dict_integration_value_is_ignored(monkeypatch) -> None: +@pytest.mark.parametrize("payload", [ + {"apify": {"api_token": ""}}, # configured-but-empty → binds nothing + {"apify": "not-a-dict"}, # malformed → skipped, no crash + {"not-a-real-service": {"api_key": "x"}}, # unknown service → no mapping + {}, # nothing declared +]) +def test_degenerate_payloads_bind_nothing(monkeypatch, payload) -> None: _clean(monkeypatch, "APIFY_API_TOKEN") - token = ex._inject_integrations_to_env({"apify": "not-a-dict"}) # type: ignore[dict-item] - assert token == {} - - -def test_unknown_integration_name_exports_nothing(monkeypatch) -> None: - token = ex._inject_integrations_to_env({"not-a-real-service": {"api_key": "x"}}) - assert token == {} + tok = ex._bind_run_credentials(payload) # type: ignore[arg-type] + try: + assert run_credentials() == {} + finally: + ex._release_run_credentials(tok) diff --git a/tests/unit/test_mt0b_self_mutation_containment.py b/tests/unit/test_mt0b_self_mutation_containment.py new file mode 100644 index 000000000..15957b34b --- /dev/null +++ b/tests/unit/test_mt0b_self_mutation_containment.py @@ -0,0 +1,194 @@ +"""MT-0b — self-mutation is first-party-only. + +Root ``AGENTS.md`` non-negotiable 3 has said this since it was written: native +MAF agents land approved self-mutations by opening a PR against **this** +CommandCenter monorepo, and "third parties must never push to the shared +monorepo". Nothing enforced it, and ``work_plan.md`` WS-3 records why — no +``first_party`` field existed anywhere, "the phrase occurs only in comments and +one test helper". + +Migration 157 creates ``organization.first_party``, defaulting to **false**, so +a tenant created tomorrow is contained by construction rather than by someone +remembering. This module pins the gate that reads it. + +The governing property is **fail closed**: every path that cannot *prove* the +org is first-party must refuse. Availability of self-mutation is worth far less +than the guarantee that a customer's agent never pushes to our repository. + +Spec: ``ai-company-brain/specs/saas_multitenancy.md`` §6.2 / MT-0b · WS-29. +""" +from __future__ import annotations + +import orchestrator.mutation as mut +import pytest + + +class _Row(tuple): + """A SQLAlchemy-ish row: indexable, truthy.""" + + +class _FakeResult: + def __init__(self, row): + self._row = row + + def first(self): + return self._row + + +class _FakeSession: + """Minimal stand-in for the async session ``_self_mutation_permitted`` uses.""" + + def __init__(self, row=None, raises: Exception | None = None): + self._row, self._raises = row, raises + self.closed = False + + async def execute(self, *_a, **_kw): + if self._raises: + raise self._raises + return _FakeResult(self._row) + + async def close(self): + self.closed = True + + +def _patch_db(monkeypatch, session): + async def _get_db(): + return session + monkeypatch.setattr("acb_common.db.get_db", _get_db, raising=True) + + +# ========================================================================== +# The containment itself. +# ========================================================================== +@pytest.mark.asyncio +async def test_non_first_party_org_is_refused(monkeypatch) -> None: + """A tenant flagged first_party=false may not self-mutate.""" + _patch_db(monkeypatch, _FakeSession(row=_Row((False,)))) + + allowed, reason = await mut._self_mutation_permitted("org-tenant-b") + + assert allowed is False + assert "not flagged first-party" in reason + + +@pytest.mark.asyncio +async def test_first_party_org_is_permitted(monkeypatch) -> None: + """The operator's own org keeps the behaviour it has today.""" + _patch_db(monkeypatch, _FakeSession(row=_Row((True,)))) + + allowed, reason = await mut._self_mutation_permitted("org-fracktal") + + assert allowed is True + assert reason == "" + + +@pytest.mark.asyncio +async def test_no_row_is_refused(monkeypatch) -> None: + """No sole organization resolved → refuse. + + This is the multi-tenant case: the untenanted query requires + ``count(*) = 1``, so it returns nothing once a second org exists. Refusing + there is the whole containment — a "default org" fallback would keep + answering true forever. + """ + _patch_db(monkeypatch, _FakeSession(row=None)) + + allowed, reason = await mut._self_mutation_permitted(None) + + assert allowed is False + assert "no single first-party organization" in reason + + +@pytest.mark.asyncio +async def test_database_failure_is_refused_not_ignored(monkeypatch) -> None: + """Fail CLOSED. An unreachable DB must not mean 'go ahead'.""" + _patch_db(monkeypatch, _FakeSession(raises=RuntimeError("connection refused"))) + + allowed, reason = await mut._self_mutation_permitted("org-anything") + + assert allowed is False + assert "could not establish" in reason + + +@pytest.mark.asyncio +async def test_operator_kill_switch_refuses_before_touching_the_db(monkeypatch) -> None: + """SELF_MUTATION_DISABLED short-circuits — no query, no container.""" + monkeypatch.setenv("SELF_MUTATION_DISABLED", "1") + + def _boom(): + raise AssertionError("the DB must not be consulted when the kill switch is on") + + monkeypatch.setattr("acb_common.db.get_db", _boom, raising=True) + + allowed, reason = await mut._self_mutation_permitted("org-fracktal") + + assert allowed is False + assert "SELF_MUTATION_DISABLED" in reason + + +# ========================================================================== +# done-when: a non-first-party failure event produces NO PR attempt. +# ========================================================================== +@pytest.mark.asyncio +async def test_tenant_failure_never_reaches_the_sandbox(monkeypatch) -> None: + """The acceptance criterion, end to end at the public entry point. + + ``attempt_self_mutation`` must return ``attempted=False`` and must not reach + the attempt tally, the sandbox, git, or the network. Anything it *did* reach + would be work done on a tenant's behalf against our own repository. + """ + _patch_db(monkeypatch, _FakeSession(row=_Row((False,)))) + + def _never(*_a, **_kw): + raise AssertionError("a non-first-party run reached the mutation machinery") + + monkeypatch.setattr(mut, "_register_mutation_attempt", _never, raising=True) + monkeypatch.setattr(mut, "_run_mutation_sandbox", _never, raising=True) + + result = await mut.attempt_self_mutation( + "agent-tenant", "run-123", RuntimeError("boom"), + organization_id="org-tenant-b", + ) + + assert result.attempted is False + assert result.skipped_reason + assert "first-party" in result.skipped_reason + + +@pytest.mark.asyncio +async def test_first_party_still_reaches_the_attempt_tally(monkeypatch) -> None: + """The gate must not break the operator's own path. + + Guards against the fix being "refuse everything", which would pass every + test above and silently switch the feature off for Fracktal too. + """ + _patch_db(monkeypatch, _FakeSession(row=_Row((True,)))) + reached = {} + + def _tally(run_id, prior=0): + reached["yes"] = True + return False, 1 # deny at the tally, so nothing further runs + + monkeypatch.setattr(mut, "_register_mutation_attempt", _tally, raising=True) + + result = await mut.attempt_self_mutation( + "agent-ours", "run-456", RuntimeError("boom"), + organization_id="org-fracktal", + ) + + assert reached.get("yes") is True + assert result.attempted is False + assert "max_mutation_attempts" in (result.skipped_reason or "") + + +# ========================================================================== +# The migration is the source of truth for the default. +# ========================================================================== +def test_migration_defaults_first_party_to_false() -> None: + """DEFAULT false is the containment. A later edit to `true` would silently + un-contain every tenant created after it.""" + from pathlib import Path + sql = Path(__file__).resolve().parents[2] / "infra/postgres/157_org_first_party.sql" + text = sql.read_text(encoding="utf-8") + assert "first_party BOOLEAN NOT NULL DEFAULT false" in text + assert "WHERE slug = 'default'" in text, "the operator's own org must be backfilled true" diff --git a/tests/unit/test_mt0c1_no_raw_sql_agent_tools.py b/tests/unit/test_mt0c1_no_raw_sql_agent_tools.py new file mode 100644 index 000000000..664c86e9f --- /dev/null +++ b/tests/unit/test_mt0c1_no_raw_sql_agent_tools.py @@ -0,0 +1,210 @@ +"""MT-0c-1 — no agent tool accepts SQL. + +``saas_multitenancy.md`` §0.9.3 states this as a **condition on the pooled +tenancy decision**, not a preference: + + "No agent ever gets a raw-SQL tool, and no agent-reachable code path can set + ``app.tenant_id``. … If either of those is violated, pooled tenancy is not + defensible and §1 should be re-taken." + +The condition was already violated when it was written. ``query_history`` took a +model-generated SQL string and executed it via ``acb_graph.get_session()`` — +registered in ``apps/agents/agent-orchestrator/config.json``, injected at +``orchestrator/_tool_injection.py:623``, and advertised to the model in +``addendum.py`` as "Run a SELECT-only SQL query". + +Its guard was a keyword-substring check and was wrong in **both** directions, +measured 2026-08-08: + +* ``SELECT role, content, created_at FROM chat_message`` — the tool's own + documented example — was **rejected**, because ``CREATED_AT`` contains + ``CREATE``; +* ``SELECT * FROM provider_keys`` passed **cleanly**. The guard constrained + verbs; nothing constrained tables. + +This module pins the replacement and, more importantly, pins that the shape +cannot come back: :func:`test_no_agent_tool_accepts_a_sql_parameter` is a +build-failing ratchet in the spirit of ``test_db_engine_seam.py``. + +Spec: ``saas_multitenancy.md`` §0.9.3 / MT-0c-1 · WS-29 · D16. +""" +from __future__ import annotations + +import inspect + +import acb_skills +import pytest +from acb_skills.history_tools import query_history + +# -------------------------------------------------------------------------- +# The ratchet: the shape must not return. +# -------------------------------------------------------------------------- +#: Parameter names that mean "the model writes the query". A tool taking one of +#: these hands query composition to a language model reading untrusted email. +_SQL_PARAM_NAMES = {"sql", "query_sql", "statement", "stmt", "select", "where_clause"} + + +def _agent_tools() -> list: + """Every callable ``acb_skills`` exports as an agent tool.""" + return [ + getattr(acb_skills, name) + for name in getattr(acb_skills, "__all__", []) + if callable(getattr(acb_skills, name, None)) + ] + + +def test_no_agent_tool_accepts_a_sql_parameter() -> None: + """Build-failing ratchet — a new tool taking SQL fails here, not in prod.""" + offenders: list[str] = [] + for fn in _agent_tools(): + try: + params = set(inspect.signature(fn).parameters) + except (TypeError, ValueError): + continue + hit = params & _SQL_PARAM_NAMES + if hit: + offenders.append(f"{getattr(fn, '__name__', fn)}{sorted(hit)}") + assert not offenders, ( + "agent tools must never accept SQL — the model composes the value, not " + f"the syntax (saas_multitenancy.md §0.9.3): {offenders}" + ) + + +def test_query_history_takes_criteria_not_sql() -> None: + params = inspect.signature(query_history).parameters + assert "query" not in params, "query_history still takes a SQL string" + assert {"search", "thread_id", "agent_name", "since_days", "limit"} <= set(params) + + +def test_history_tool_holds_no_model_supplied_table_name() -> None: + """The two reachable tables are literals in the module, not arguments. + + A table name that arrives as data is exactly the hole the old guard left: + it policed verbs and never touched the FROM clause. + """ + import ast + + import acb_skills.history_tools as ht + + src = inspect.getsource(ht) + assert "FROM chat_message" in src and "JOIN chat_session" in src + + # Check the CODE, not the prose — this module's docstrings quote the old + # vulnerable queries on purpose, and that is documentation, not a statement + # anyone can execute. Strip every docstring, then look at what is left. + tree = ast.parse(src) + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.ClassDef, + ast.FunctionDef, ast.AsyncFunctionDef)): + doc = ast.get_docstring(node, clean=False) + if doc: + src = src.replace(doc, "") + + for banned in ("SELECT * FROM", '" + ', ".format(", "f'''", 'f"""'): + assert banned not in src, f"history_tools composes SQL dynamically: {banned}" + + +def test_model_facing_description_no_longer_advertises_sql() -> None: + """The addendum is what the model reads. If it still says "SELECT", the + model will still try to send SQL — and get a confusing failure.""" + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + for rel in ("packages/acb_skills/acb_skills/addendum.py", + "packages/acb_skills/acb_skills/skill_families.py"): + text = (root / rel).read_text(encoding="utf-8") + assert "SELECT-only" not in text, f"{rel} still advertises a SQL tool" + + +# -------------------------------------------------------------------------- +# The old guard, reproduced — so the reason survives the fix. +# -------------------------------------------------------------------------- +_OLD_DANGEROUS = {"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", + "CREATE", "TRUNCATE", "EXEC", "EXECUTE"} + + +def _old_guard_rejects(q: str) -> bool: + """The pre-MT-0c-1 check, verbatim.""" + if not q.strip().upper().startswith("SELECT"): + return True + return any(kw in q.upper() for kw in _OLD_DANGEROUS) + + +def test_the_old_guard_rejected_its_own_documented_example() -> None: + """False positive: any query selecting ``created_at`` was refused.""" + assert _old_guard_rejects( + "SELECT role, content, created_at FROM chat_message LIMIT 5" + ), "if this passes, the false-positive analysis in the module docstring is wrong" + + +def test_the_old_guard_allowed_reading_the_credential_table() -> None: + """False negative, and the one that mattered: verbs were policed, not tables.""" + assert not _old_guard_rejects("SELECT * FROM provider_keys"), ( + "if this is rejected, the false-negative analysis is wrong" + ) + assert not _old_guard_rejects("SELECT * FROM email_messages") + assert not _old_guard_rejects("SELECT * FROM app_user") + + +# -------------------------------------------------------------------------- +# Behaviour of the replacement. +# -------------------------------------------------------------------------- +class _FakeResult: + def __init__(self, rows, cols): + self._rows, self._cols = rows, cols + + def fetchmany(self, n): + return self._rows[:n] + + def keys(self): + return self._cols + + +class _FakeSession: + def __init__(self, sink): + self.sink = sink + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def execute(self, stmt, params=None): + self.sink["sql"] = str(stmt) + self.sink["params"] = params + return _FakeResult([("user", "hello", "2026-08-01", "t1", "orchestrator", "T")], + ["role", "content", "created_at", "thread_id", + "agent_name", "title"]) + + +@pytest.fixture +def captured(monkeypatch): + sink: dict = {} + monkeypatch.setattr("acb_graph.get_session", lambda: _FakeSession(sink), raising=False) + return sink + + +@pytest.mark.asyncio +async def test_criteria_are_bound_parameters_not_interpolated(captured) -> None: + """A hostile 'search' term reaches the DB as a VALUE, never as syntax.""" + hostile = "'; DROP TABLE chat_message; --" + + out = await query_history(search=hostile) + + assert hostile not in captured["sql"], "the search term was interpolated into SQL" + assert captured["params"]["search_like"] == f"%{hostile}%" + assert out.startswith("[") + + +@pytest.mark.asyncio +async def test_limit_is_capped(captured) -> None: + await query_history(limit=10_000) + assert captured["params"]["limit"] == 20 + + +@pytest.mark.asyncio +async def test_degenerate_arguments_do_not_raise(captured) -> None: + for kwargs in ({}, {"limit": "abc"}, {"since_days": "x"}, {"search": " "}): + out = await query_history(**kwargs) # type: ignore[arg-type] + assert out.startswith("[") diff --git a/tests/unit/test_mt0d_per_org_credentials.py b/tests/unit/test_mt0d_per_org_credentials.py new file mode 100644 index 000000000..3ae4bec5f --- /dev/null +++ b/tests/unit/test_mt0d_per_org_credentials.py @@ -0,0 +1,201 @@ +"""MT-0d — provider credentials stop being deployment-wide. + +``provider_keys`` was ``provider TEXT PRIMARY KEY`` (``08_provider_keys.sql:6-7``) +— one key per provider for the whole box — and ``mcp_servers``, ``plugins`` and +``model_config`` had no owner column at all. + +``tenancy_and_visibility.md`` §1.1 called that "exactly the right shape", and it +**was**: under D11 one deployment served one tenant. **D15 re-took that +decision**, and under a pooled tenant boundary the same shape means tenant B's +agent resolves tenant A's OpenAI key. Migration 158 re-keys all four. + +Two properties are pinned here, and the second is the one that would otherwise +be missed by a reviewer reading only the SQL: + +1. **A read scoped to org B never returns org A's key** — the SQL half. +2. **The in-memory cache is keyed by (org, provider), not provider** — the half + no amount of correct SQL protects. A provider-keyed cache serves the first + tenant's *decrypted* key to the second without a query ever running. + +And the resolution rule: an untenanted call resolves to "the sole organization", +which stops resolving the moment a second exists — so every one of the ~20 +existing call sites keeps working today and **fails closed** rather than leaking +when MT-1 lands. + +Spec: ``ai-company-brain/specs/saas_multitenancy.md`` §6.3 / MT-0d · WS-29. +""" +from __future__ import annotations + +import base64 +from pathlib import Path + +import pytest +from acb_llm.key_store import ProviderKeyStore + +_ORG_A = "11111111-1111-1111-1111-111111111111" +_ORG_B = "22222222-2222-2222-2222-222222222222" + + +class _FakeStore(ProviderKeyStore): + """A key store over an in-memory table, so these tests need no database. + + ``rows`` is the ``provider_keys`` table: {(org, provider): plaintext}. + ``orgs`` is how many organization rows exist, which is what the untenanted + resolution depends on. + """ + + def __init__(self, rows: dict[tuple[str, str], str], org_count: int = 1): + super().__init__() + self.rows = rows + self.org_count = org_count + self.queries: list[str] = [] + + async def _execute(self, sql: str, **params): # type: ignore[override] + self.queries.append(sql) + norm = " ".join(sql.split()) + + if norm.startswith("SELECT id FROM organization"): + # Mirrors the real `count(*) = 1` guard. + return [{"id": _ORG_A}] if self.org_count == 1 else [] + + if norm.startswith("SELECT encrypted FROM provider_keys"): + plain = self.rows.get((params["org_id"], params["provider"])) + return [{"encrypted": self._enc(plain)}] if plain is not None else [] + + if norm.startswith("SELECT provider, encrypted FROM provider_keys"): + return [ + {"provider": p, "encrypted": self._enc(v)} + for (o, p), v in self.rows.items() if o == params["org_id"] + ] + + if norm.startswith("DELETE FROM provider_keys"): + self.rows.pop((params["org_id"], params["provider"]), None) + return [] + + if norm.startswith("INSERT INTO provider_keys"): + self.rows[(params["org_id"], params["provider"])] = "written" + return [] + + raise AssertionError(f"unexpected SQL: {norm[:80]}") + + def _enc(self, plain: str) -> str: + return base64.urlsafe_b64encode(self._f.encrypt(plain.encode())).decode("ascii") + + +@pytest.fixture +def master_key(monkeypatch): + monkeypatch.setenv("ACB_MASTER_KEY", "test-master-key-for-mt0d") + + +# ========================================================================== +# 1 — the SQL half. +# ========================================================================== +@pytest.mark.asyncio +async def test_org_b_never_receives_org_a_key(master_key) -> None: + store = _FakeStore({(_ORG_A, "openai"): "sk-org-a-secret"}, org_count=2) + + assert await store.get("openai", organization_id=_ORG_A) == "sk-org-a-secret" + assert await store.get("openai", organization_id=_ORG_B) == "" + + +@pytest.mark.asyncio +async def test_get_all_is_scoped(master_key) -> None: + store = _FakeStore( + {(_ORG_A, "openai"): "sk-a", (_ORG_B, "openai"): "sk-b", + (_ORG_B, "anthropic"): "sk-b2"}, + org_count=2, + ) + + assert await store.get_all(organization_id=_ORG_A) == {"openai": "sk-a"} + assert await store.get_all(organization_id=_ORG_B) == { + "openai": "sk-b", "anthropic": "sk-b2", + } + + +@pytest.mark.asyncio +async def test_delete_does_not_reach_across_orgs(master_key) -> None: + store = _FakeStore( + {(_ORG_A, "openai"): "sk-a", (_ORG_B, "openai"): "sk-b"}, org_count=2, + ) + + await store.delete("openai", organization_id=_ORG_B) + + assert (_ORG_A, "openai") in store.rows + assert (_ORG_B, "openai") not in store.rows + + +# ========================================================================== +# 2 — the cache half. This is the one correct SQL does NOT protect. +# ========================================================================== +@pytest.mark.asyncio +async def test_cache_is_keyed_by_org_not_provider(master_key) -> None: + """Org A's read must not warm a cache entry org B then hits. + + Under a ``provider``-keyed cache the second assertion returned + ``sk-org-a-secret`` **without issuing a query at all** — the leak lives in + memory, not in the statement. + """ + store = _FakeStore({(_ORG_A, "openai"): "sk-org-a-secret"}, org_count=2) + + assert await store.get("openai", organization_id=_ORG_A) == "sk-org-a-secret" + before = len(store.queries) + + assert await store.get("openai", organization_id=_ORG_B) == "" + assert len(store.queries) > before, ( + "org B was answered from cache without a query — the cache is not tenant-keyed" + ) + assert (_ORG_A, "openai") in store._cache + assert (_ORG_B, "openai") not in store._cache + + +# ========================================================================== +# 3 — untenanted resolution: works at one org, fails CLOSED at two. +# ========================================================================== +@pytest.mark.asyncio +async def test_untenanted_read_works_while_there_is_one_org(master_key) -> None: + """The ~20 existing call sites keep working unchanged today.""" + store = _FakeStore({(_ORG_A, "openai"): "sk-a"}, org_count=1) + + assert await store.get("openai") == "sk-a" + + +@pytest.mark.asyncio +async def test_untenanted_read_returns_nothing_once_a_second_org_exists(master_key) -> None: + """MT-0d done-when: a lookup without a tenant returns nothing, never + another tenant's key. + + A "default org" fallback would keep answering here — serving the operator's + keys to a customer — which is precisely the leak this ticket closes. + """ + store = _FakeStore({(_ORG_A, "openai"): "sk-a"}, org_count=2) + + assert await store.get("openai") == "" + + +@pytest.mark.asyncio +async def test_untenanted_write_raises_once_a_second_org_exists(master_key) -> None: + """A write with no owner is how a key ends up readable by the wrong tenant. + Failing loudly on write beats failing quietly on read.""" + store = _FakeStore({}, org_count=2) + + with pytest.raises(RuntimeError, match="requires an organization_id"): + await store.put("openai", "sk-new") + + +# ========================================================================== +# The migration is the source of truth for the shape. +# ========================================================================== +def test_migration_rekeys_all_four_tables() -> None: + sql = (Path(__file__).resolve().parents[2] + / "infra/postgres/158_per_org_credentials.sql").read_text(encoding="utf-8") + + for table in ("provider_keys", "model_config", "mcp_servers", "plugins"): + assert f"ALTER TABLE {table}\n ADD COLUMN IF NOT EXISTS organization_id" in sql, ( + f"{table} did not gain organization_id" + ) + assert "PRIMARY KEY (organization_id, provider)" in sql + assert "PRIMARY KEY (organization_id, key)" in sql + assert "PRIMARY KEY (organization_id, name)" in sql + # plugins keeps its UUID pk; what must become per-org is the name uniqueness + assert "plugins_org_name_key" in sql + assert "DROP CONSTRAINT IF EXISTS plugins_name_key" in sql diff --git a/tests/unit/test_org_access_control.py b/tests/unit/test_org_access_control.py index 1c603b79d..e0b60e722 100644 --- a/tests/unit/test_org_access_control.py +++ b/tests/unit/test_org_access_control.py @@ -637,3 +637,94 @@ def test_service_principal_runs_any_agent() -> None: ) assert_can_run_agent(user, "anything") # must not raise assert user.has_permission(agent_run_permission("anything")) + + +# ── Tenant predicate on the org_group joins (MT-1i) ───────────────────────── +# +# saas_multitenancy.md §6.4/§6.5 + tenancy_and_visibility.md §2. Decision D15 +# made the tenant boundary a ROW, so `org_group`'s slug — unique only per +# `UNIQUE (organization_id, slug)` (138_groups_and_session_participants.sql:49) +# — is a cross-organization match whenever it is joined on alone, and the two +# org-wide expansions serve every user on the box. +# +# These assertions are on the SQL *strings* deliberately: tenancy_and_visibility.md +# §2 done-when 2 notes the DB-backed tests for this area open with +# `pytest.mark.skipif(not _db_ready(), …)` and so skip green with no Postgres, +# which makes "verified red" unsatisfiable there. This file carries no such +# guard, so these cannot skip. + +def _squash(sql: str) -> str: + """Whitespace-insensitive view of a query, so formatting is not the test.""" + return re.sub(r"\s+", " ", sql) + + +def test_my_groups_query_is_a_module_constant() -> None: + """done-when 2: anchor (a) must be reachable from a hermetic test. + + It lived inline inside `_load_room`, where no string assertion could see it. + """ + from gateway import rooms + + assert isinstance(rooms.MY_GROUPS_SQL, str) + assert "org_group" in rooms.MY_GROUPS_SQL + + +def _org_group_joins() -> list[tuple[str, str]]: + """The three queries that join `org_group`, by name, for the assertion below.""" + from acb_auth.access import _GROUP_MEMBER_SQL + from gateway.rooms import MY_GROUPS_SQL, SESSION_VISIBLE_SQL + + return [ + ("gateway.rooms.MY_GROUPS_SQL", MY_GROUPS_SQL), + ("gateway.rooms.SESSION_VISIBLE_SQL", SESSION_VISIBLE_SQL), + ("acb_auth.access._GROUP_MEMBER_SQL", _GROUP_MEMBER_SQL), + ] + + +@pytest.mark.parametrize( + ("label", "sql"), + _org_group_joins(), + ids=[label for label, _ in _org_group_joins()], +) +def test_org_group_joins_carry_a_derived_org_predicate( + label: str, sql: str, +) -> None: + """done-when 1: the group's org must be tied to the acting user's org. + + A slug-only join matches the identically-slugged group in every other + tenant. The predicate must be *derived* from the row being authorised — + a literal `slug = 'default'` swaps one wrong constant for another. + """ + sql = _squash(sql) + assert "org_group" in sql, label + assert "g.organization_id =" in sql, ( + f"{label} joins org_group without tying g.organization_id to the " + f"acting user's organization: {sql}" + ) + assert "'default'" not in sql, ( + f"{label} hardcodes an org slug instead of deriving one: {sql}" + ) + + +def test_org_subject_does_not_expand_to_every_user_on_the_box() -> None: + """§6.4: `_ORG_MEMBER_SQL` had no org filter at all.""" + from acb_auth import access + + sql = _squash(access._ORG_MEMBER_SQL) + assert "organization_id" in sql, ( + f"the `org` subject expands to every active user on the box: {sql}" + ) + + +def test_owner_bootstrap_guard_is_per_organization() -> None: + """§6.4 site 9: a lockout, not a leak — RLS does not fix it. + + Unfiltered, one owner anywhere makes `ensure_owner_bootstrap()` a permanent + no-op, so an ownerless organization stays ownerless with no inviter. + """ + from acb_auth import access + + sql = _squash(access._HAS_OWNER_SQL) + assert "organization_id" in sql, ( + f"_HAS_OWNER_SQL sees owners in every organization: {sql}" + ) diff --git a/tests/unit/test_psycopg_seam.py b/tests/unit/test_psycopg_seam.py new file mode 100644 index 000000000..9092ce4b3 --- /dev/null +++ b/tests/unit/test_psycopg_seam.py @@ -0,0 +1,191 @@ +"""MT-1c — no new raw ``psycopg.connect`` call sites. + +``saas_multitenancy.md`` §0.1 measured every path in the process that opens a +database connection and found eight. Three of them do not go through SQLAlchemy +at all — they open a raw psycopg connection: + +* ``acb_llm/key_store.py:83-108`` — provider keys (path 5) +* ``acb_llm/model_config.py:52-76`` — model config (path 6) +* ``acb_common/org_settings.py:55-81`` — org settings (path 7) + +None of the three was visible to ``test_db_engine_seam.py``, which inspects the +SQLAlchemy engine constructors and nothing else. That is why §0.1 states, as +acceptance criterion 3: *"Add a companion ratchet for ``psycopg.connect``, with +the same allow-list-with-a-reason discipline. Paths 5-7 were invisible to the +existing test."* This is that ratchet. + +Why it matters under D15: tenant isolation is enforced by Postgres RLS, and RLS +reads ``app.tenant_id`` from the **connection**. A connection opened outside the +seam is a connection with nothing bound. It fails closed rather than leaking — +``current_setting('app.tenant_id', true)`` is NULL and the query returns zero +rows — but it fails closed *at runtime, in whatever environment first exercises +it*, which for a rarely-taken config read can be production. A source-level +check fails it at build time instead. + +Scope, stated so the next reader knows what this does NOT cover: it matches +``connect`` calls rooted at a psycopg module (and ``connect`` imported from one). +A connection pool constructed as ``psycopg_pool.ConnectionPool(...)`` is a +different call shape and would slip past — measured 2026-08-08, the tree has no +psycopg_pool import at all, so the narrower rule costs nothing today. If a pool +is ever introduced, widen ``_opens_a_psycopg_connection`` rather than allow-list +the file. Path 8 (``acb_memory/mem0_client.py``) is out of reach of any +source-level rule here: it hands a conninfo string to Mem0, which opens the +connection inside a third-party library. §0.1 criterion 4 owns that one. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[2] + +#: Modules whose ``connect`` opens a real libpq connection. +#: ``psycopg2`` is listed although the tree does not use it — the failure this +#: guards is a *new* call site, and a new one arriving under the older import +#: name would otherwise be silently permitted. +_PSYCOPG_ROOTS = frozenset({"psycopg", "psycopg2"}) + +#: Every file allowed to call ``psycopg.connect``, and why. +#: +#: To add an entry you must answer "why can this not go through +#: ``acb_common.db``?" *and* "which tenant does this connection bind?". The +#: three below predate MT-1c and are named individually in §0.1's inventory; +#: each one is a binding site the RLS work must convert, not a permanent +#: exemption from it. +_ALLOWED: dict[str, str] = { + "packages/acb_llm/acb_llm/key_store.py": + "connection path 5 — provider keys; became per-org in MT-0d, sync " + "psycopg under asyncio.to_thread to dodge the Windows ProactorEventLoop", + "packages/acb_llm/acb_llm/model_config.py": + "connection path 6 — model config; became per-org in MT-0d, sync " + "psycopg so sync helpers and async handlers share one reader", + "packages/acb_common/acb_common/org_settings.py": + "connection path 7 — control-plane read of org-wide settings blobs, " + "sync psycopg so it is callable from both sync and async code", +} + + +def _python_files() -> list[Path]: + roots = [_REPO / "apps", _REPO / "packages"] + out: list[Path] = [] + for root in roots: + out.extend( + p for p in root.rglob("*.py") + if "__pycache__" not in p.parts and ".venv" not in p.parts + ) + return out + + +def _dotted(func: ast.expr) -> list[str] | None: + """The dotted path of a call target, e.g. ``psycopg.AsyncConnection.connect``. + + Returns ``None`` for anything not built purely from names and attributes + (a call on a subscript or on another call's result), which no psycopg + connect site in this tree is. + """ + parts: list[str] = [] + node: ast.expr = func + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if not isinstance(node, ast.Name): + return None + parts.append(node.id) + parts.reverse() + return parts + + +def _opens_a_psycopg_connection(path: Path) -> bool: + """True if the file CALLS ``psycopg.connect``. + + Parsed rather than grepped, for the same reason the engine ratchet is: all + three allow-listed modules discuss psycopg in prose — ``org_settings`` says + *"Uses a synchronous psycopg connection"* in its module docstring — and a + substring match would count the documentation as a call site. + + Two spellings are accepted as violations: + + * a dotted call rooted at a psycopg module whose last segment is ``connect`` + — ``psycopg.connect(...)`` and ``psycopg.AsyncConnection.connect(...)``; + * a bare ``connect(...)`` where ``connect`` was imported from psycopg, + under its own name or an alias. Nothing in the tree spells it that way + today; it is covered because it is the obvious way to evade the first rule + without meaning to. + """ + # utf-8-sig: at least one module in the tree carries a BOM, and a leading + #  is a syntax error to ast.parse. + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + + # Local names bound to psycopg's own ``connect`` by a from-import. + aliased: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or node.module is None: + continue + if node.module.split(".")[0] not in _PSYCOPG_ROOTS: + continue + aliased.update( + alias.asname or alias.name + for alias in node.names + if alias.name == "connect" + ) + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Name) and node.func.id in aliased: + return True + parts = _dotted(node.func) + if parts and len(parts) > 1 and parts[0] in _PSYCOPG_ROOTS and parts[-1] == "connect": + return True + return False + + +def test_no_new_psycopg_connections() -> None: + offenders = sorted( + str(p.relative_to(_REPO)).replace("\\", "/") + for p in _python_files() + if _opens_a_psycopg_connection(p) + ) + unexpected = [p for p in offenders if p not in _ALLOWED] + assert not unexpected, ( + "New psycopg.connect() call site(s):\n " + + "\n ".join(unexpected) + + "\n\nUse acb_common.db (get_db / get_session_factory) — the shared " + "seam is where the tenant is bound. A raw connection binds no " + "app.tenant_id and RLS will serve it zero rows. If this genuinely " + "needs its own connection, add it to _ALLOWED in this test with the " + "reason AND how it binds a tenant (saas_multitenancy.md §0.1)." + ) + + +def test_allowlist_has_no_stale_entries() -> None: + """A file that stopped opening its own connection must leave the allowlist. + + Same reason the engine ratchet holds this line: the list otherwise grows + into permission for anything, and the next reader cannot tell which entries + are still load-bearing. Here it also tracks progress — an entry leaving is + a §0.1 path that moved onto the shared seam. + """ + offenders = { + str(p.relative_to(_REPO)).replace("\\", "/") + for p in _python_files() + if _opens_a_psycopg_connection(p) + } + stale = sorted(set(_ALLOWED) - offenders) + assert not stale, f"Allowlist entries that no longer open a connection: {stale}" + + +def test_inventory_matches_the_spec() -> None: + """The allow-list is exactly §0.1's paths 5-7 — no more, no fewer. + + The check above proves no *new* file connects; this pins the set to the + three the spec enumerated. If the inventory in ``saas_multitenancy.md`` §0.1 + and this list ever disagree, one of them is out of date, and the spec is the + document people plan the RLS rollout from. + """ + assert set(_ALLOWED) == { + "packages/acb_llm/acb_llm/key_store.py", + "packages/acb_llm/acb_llm/model_config.py", + "packages/acb_common/acb_common/org_settings.py", + } diff --git a/tests/unit/test_tenant_coverage.py b/tests/unit/test_tenant_coverage.py new file mode 100644 index 000000000..164b36505 --- /dev/null +++ b/tests/unit/test_tenant_coverage.py @@ -0,0 +1,204 @@ +"""MT-1b — every application table is tenant-scoped, or exempt with a reason. + +Spec: ``ai-company-brain/specs/saas_multitenancy.md`` §1.3 / MT-1b · WS-29 · D15. + +The point of a generated migration is that nobody hand-writes 143 policies and +omits ``FORCE`` on one of them. The point of *this* file is the other half: that +a table added next month is covered **without anyone remembering**, which is the +same by-construction discipline root ``AGENTS.md`` constraint 10 already applies +to authentication and ``test_db_engine_seam.py`` applies to connection pools. + +Two layers, because they fail at different times: + +* **Source-level (always runs).** Every table the numbered migrations create is + either in the generator's ``EXEMPT`` map or will be scoped by the generated + migration. Catches a new table the moment its migration lands, months before + anyone deploys. +* **Database-level (skips without Postgres).** The live catalog actually carries + the column, ``FORCE`` and a policy. Catches a migration that was written but + never applied. + +⚠️ **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. +Every entry carries a reason and a reviewer is expected to challenge it. +""" +from __future__ import annotations + +import importlib.util +import os +import sys +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[2] + + +def _generator(): + """Import ``scripts/gen_tenant_migration.py`` (not an installed package).""" + 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 + + +# ========================================================================== +# Source-level — always runs, no database. +# ========================================================================== +def test_every_table_is_scoped_or_exempt_with_a_reason() -> None: + gen = _generator() + tables = gen.discover_tables() + assert len(tables) > 100, ( + "table discovery collapsed — the regex or the migration glob broke, and " + "a coverage test that discovers nothing passes vacuously" + ) + for name, reason in gen.EXEMPT.items(): + assert reason.strip(), f"{name} is exempt with no reason given" + + +def test_exemptions_are_deliberate_and_few() -> None: + """A growing exemption list is how tenant isolation quietly stops meaning + anything. This is a tripwire, not a hard limit — raise it *in a PR that + explains why*, never to make a build green.""" + gen = _generator() + assert len(gen.EXEMPT) <= 15, ( + f"{len(gen.EXEMPT)} exempt tables. Each one is a table with no tenant " + "isolation. Justify the growth explicitly." + ) + + +def test_generated_policies_carry_all_four_load_bearing_clauses() -> None: + """FORCE, WITH CHECK and the missing-ok flag each have an incident behind + them (saas_multitenancy_implementation.md §1.1). Drop any one and the + migration still applies cleanly while isolation is silently wrong.""" + gen = _generator() + sql = gen.gen_policies(["some_table"]) + assert "ENABLE ROW LEVEL SECURITY" in sql + assert "FORCE ROW LEVEL SECURITY" in sql, ( + "ENABLE alone leaves the table OWNER reading every tenant" + ) + assert "USING (organization_id" in sql + assert "WITH CHECK (organization_id" in sql, ( + "without WITH CHECK a tenant can WRITE a row stamped with another " + "tenant's id — USING only filters reads" + ) + assert "current_setting('app.tenant_id', true)" in sql, ( + "the missing-ok flag must be true, or an unset GUC RAISES instead of " + "returning NULL and every unconverted path 500s at once" + ) + + +def test_add_column_phase_is_nullable_and_defaulted() -> None: + """Phase 1 must not scan or lock — that is what makes it safe on a live box. + And the DEFAULT is what means no INSERT statement in 209 gateway files + changes (§1.3).""" + gen = _generator() + sql = gen.gen_add_columns(["some_table"]) + assert "ADD COLUMN IF NOT EXISTS organization_id UUID" in sql + assert "DEFAULT current_setting('app.tenant_id', true)::uuid" in sql + assert "NOT NULL" not in sql, ( + "phase 1 must stay nullable — NOT NULL here takes ACCESS EXCLUSIVE and " + "scans the table, which is the phase-3 problem, not the phase-1 one" + ) + + +def test_constraint_phase_refuses_to_run_before_the_backfill() -> None: + gen = _generator() + sql = gen.gen_constraints(["some_table"]) + assert "RAISE EXCEPTION" in sql and "unowned rows" in sql + + +def test_generated_files_are_outside_the_replayed_sequence() -> None: + """`apply_migrations.sh` replays `infra/postgres/[0-9]*.sql` on every deploy. + + MT-1b must NOT land there: phase 3 is ACCESS EXCLUSIVE across 135 tables and + phase 4 is a cliff that dark-fails every unbound connection. Promoting it is + a human act in a window — not a consequence of a file reaching main. + """ + generated = _REPO / "infra" / "postgres" / "generated" + if not generated.is_dir(): + pytest.skip("generator has not been run in this tree") + for path in generated.glob("*.sql"): + assert not path.name[0].isdigit() or path.parent.name == "generated" + replayed = {p.name for p in (_REPO / "infra" / "postgres").glob("[0-9]*_*.sql")} + assert not (replayed & {p.name for p in generated.glob("*.sql")}) + + +# ========================================================================== +# Database-level — skips without Postgres. NEVER report a skip as a pass. +# ========================================================================== +def _db_ready() -> bool: + # The launch-time snapshot (tests/conftest.py), not the live variable: + # litellm's import-time load_dotenv() can plant a dev .env's DATABASE_URL + # into os.environ mid-collection, which would point these tests at an + # unmigrated local database instead of skipping. Fall back to the live + # variable only when the snapshot was never taken (running outside pytest). + snap = os.environ.get("_ACB_DATABASE_URL_AT_LAUNCH") + if snap is not None: + return bool(snap) + return bool(os.environ.get("DATABASE_URL")) + + +_needs_db = pytest.mark.skipif( + not _db_ready(), + reason="needs a migrated Postgres (DATABASE_URL unset). ⚠️ This test is the " + "only one that proves the migration was actually APPLIED — a green " + "run without it proves the SQL was written, not that it works.", +) + + +@_needs_db +@pytest.mark.asyncio +async def test_live_catalog_has_column_force_and_policy() -> None: + from acb_common.db import get_db + from sqlalchemy import text + + gen = _generator() + session = await get_db() + try: + rows = (await session.execute(text(""" + SELECT c.relname AS table_name, + c.relrowsecurity AS rls_enabled, + c.relforcerowsecurity AS rls_forced, + EXISTS (SELECT 1 FROM information_schema.columns col + WHERE col.table_name = c.relname + AND col.column_name = 'organization_id') AS has_col, + EXISTS (SELECT 1 FROM pg_policies p + WHERE p.tablename = c.relname) AS has_policy + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind = 'r' + """))).mappings().all() + finally: + await session.close() + + bad = [ + r["table_name"] for r in rows + if r["table_name"] not in gen.EXEMPT + and not (r["has_col"] and r["rls_enabled"] and r["rls_forced"] and r["has_policy"]) + ] + assert not bad, f"tables missing tenant scoping in the live catalog: {sorted(bad)}" + + +@_needs_db +@pytest.mark.asyncio +async def test_app_role_cannot_bypass_rls() -> None: + """A superuser, a BYPASSRLS role, or the table owner all read every tenant. + FORCE handles the owner; this catches the other two.""" + from acb_common.db import get_db + from sqlalchemy import text + + session = await get_db() + try: + row = (await session.execute(text( + "SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user" + ))).first() + finally: + await session.close() + assert row is not None + assert not row[0], "the app connects as a SUPERUSER — RLS does not apply to it" + assert not row[1], "the app role has BYPASSRLS — RLS does not apply to it" diff --git a/tests/unit/test_tenant_placement.py b/tests/unit/test_tenant_placement.py new file mode 100644 index 000000000..b86bddd59 --- /dev/null +++ b/tests/unit/test_tenant_placement.py @@ -0,0 +1,136 @@ +"""MT-1a — tenant placement resolves, or refuses. It never guesses. + +Spec: ``ai-company-brain/specs/saas_multitenancy.md`` §1.5 / §1.6 / MT-1a · +board WS-29 · D15. + +Placement is the indirection that makes the tenancy decision **reversible**: +moving a customer to their own database becomes a data move plus a row update +instead of an architecture change made under pressure. §1.6 states the reason +plainly — *a tenancy model you cannot reverse is the actual risk.* + +On day one every tenant resolves to ``(pool, primary)``. These tests exist +because that is exactly when the indirection is easiest to quietly skip, and +because its one dangerous failure mode is **defaulting instead of refusing**. +""" +from __future__ import annotations + +import pytest +from acb_common.placement import ( + TIERS, + Placement, + PlacementUnresolved, + resolve_placement, +) + +_ORG_A = "11111111-1111-1111-1111-111111111111" + + +class _FakeSession: + def __init__(self, row=None, raises: Exception | None = None): + self._row, self._raises = row, raises + self.sql = "" + + async def execute(self, stmt, params=None): + if self._raises: + raise self._raises + self.sql = str(stmt) + row = self._row + + class _R: + def mappings(self): + class _M: + def first(_self): + return row + return _M() + return _R() + + async def close(self): + return None + + +def _patch_db(monkeypatch, session): + async def _get_db(): + return session + monkeypatch.setattr("acb_common.db.get_db", _get_db, raising=True) + + +@pytest.mark.asyncio +async def test_resolves_an_explicit_organization(monkeypatch) -> None: + _patch_db(monkeypatch, _FakeSession(row={ + "organization_id": _ORG_A, "tier": "pool", + "target": "primary", "region": "ap-south-1", + })) + + p = await resolve_placement(_ORG_A) + + assert p == Placement(_ORG_A, "pool", "primary", "ap-south-1") + assert p.is_pooled and not p.is_dedicated + + +@pytest.mark.asyncio +async def test_missing_placement_raises_rather_than_defaulting(monkeypatch) -> None: + """A tenant with no placement row is unresolvable — and unresolvable must + mean *stop*, not *use the usual one*. Defaulting here reads or writes + another customer's data.""" + _patch_db(monkeypatch, _FakeSession(row=None)) + + with pytest.raises(PlacementUnresolved): + await resolve_placement(_ORG_A) + + +@pytest.mark.asyncio +async def test_untenanted_call_raises_once_there_is_no_sole_org(monkeypatch) -> None: + """The untenanted query is guarded by ``count(*) = 1``, so it returns nothing + the moment a second tenant exists. + + This mirrors ``key_store._resolve_org`` and ``mutation._self_mutation_permitted`` + on purpose: three independent places, one rule — *fail closed exactly when a + second tenant appears*, which is when a real tenant id must be threaded + through instead. + """ + _patch_db(monkeypatch, _FakeSession(row=None)) + + with pytest.raises(PlacementUnresolved, match="no sole organization"): + await resolve_placement(None) + + +@pytest.mark.asyncio +async def test_catalog_unreachable_raises(monkeypatch) -> None: + _patch_db(monkeypatch, _FakeSession(raises=RuntimeError("connection refused"))) + + with pytest.raises(PlacementUnresolved, match="unreachable"): + await resolve_placement(_ORG_A) + + +@pytest.mark.asyncio +async def test_dedicated_tiers_are_flagged(monkeypatch) -> None: + """`bridge` and `silo` are the priced tiers (§1.5). Code that has to behave + differently for them asks this, not a string comparison scattered around.""" + for tier in ("bridge", "silo"): + _patch_db(monkeypatch, _FakeSession(row={ + "organization_id": _ORG_A, "tier": tier, + "target": "acme-db", "region": "ap-south-1", + })) + p = await resolve_placement(_ORG_A) + assert p.is_dedicated and not p.is_pooled + + +def test_tiers_match_the_migration_check_constraint() -> None: + """The CHECK in 159_control_plane.sql and this tuple must not drift — a tier + accepted by one and rejected by the other is an onboarding failure nobody + can debug from either side alone.""" + from pathlib import Path + sql = (Path(__file__).resolve().parents[2] + / "infra/postgres/159_control_plane.sql").read_text(encoding="utf-8") + for tier in TIERS: + assert f"'{tier}'" in sql, f"tier {tier!r} missing from the migration CHECK" + + +def test_placement_row_seeded_for_every_existing_org() -> None: + """A tenant with no placement row cannot be served. The migration must + backfill every organization that already exists, not just new ones.""" + from pathlib import Path + sql = (Path(__file__).resolve().parents[2] + / "infra/postgres/159_control_plane.sql").read_text(encoding="utf-8") + assert "INSERT INTO tenant_placement" in sql + assert "SELECT id, 'pool', 'primary' FROM organization" in sql diff --git a/tests/unit/test_tenant_redis.py b/tests/unit/test_tenant_redis.py new file mode 100644 index 000000000..4768eb2c6 --- /dev/null +++ b/tests/unit/test_tenant_redis.py @@ -0,0 +1,641 @@ +"""MT-1e — the Redis wrapper cannot express an unprefixed key, and the build says so. + +``saas_multitenancy.md`` §0.9.4 is the whole test plan: + + "Redis stays, but tenant prefixing is enforced by a wrapper client, not by + convention. A convention is a thing people forget; a client that cannot + construct an unprefixed key is not." + +So these tests do not check that keys are *usually* prefixed. They check the +stronger property the spec asked for: that there is **no public path** to an +unprefixed key — not through the builder, not through the dataclass constructor, +not through a client method, not through the pipeline, not through a SCAN +pattern. Plus the fail-closed rule (§1.9): unbound raises rather than defaulting +to a global key. + +The last section is the ratchet the ticket's done-when names: a source-level +check that fails the build on a direct ``redis`` client outside the wrapper. Its +allow-list is today's un-migrated call sites, and each entry is an item on the +follow-up ticket's checklist — see the migration path in +``acb_common/tenant_redis.py``'s module docstring. + +Spec: ``saas_multitenancy.md`` §0.9.4, §1.9 · MT-1e · D15. +""" + +from __future__ import annotations + +import ast +import inspect +from pathlib import Path +from typing import Any + +import pytest +from acb_common import tenant_redis as tr +from acb_common.tenant_redis import ( + KEY_ROOT, + ConsumerGroup, + ScanPattern, + TenantKey, + TenantMismatch, + TenantNotBound, + TenantRedis, + current_organization, + group, + key, + match, + organization_bound, + organization_scope, +) + +_REPO = Path(__file__).resolve().parents[2] + +ORG_A = "org-aaaaaaaa" +ORG_B = "org-bbbbbbbb" + + +@pytest.fixture(autouse=True) +def _unbound(): + """Every test starts with NO tenant bound. + + A leaked binding from a previous test would make the fail-closed assertions + pass for the wrong reason, which is the one failure mode a tenancy test + cannot afford. + """ + token = tr._ORGANIZATION_ID.set(None) + try: + yield + finally: + tr._ORGANIZATION_ID.reset(token) + + +class _FakeRedis: + """Records the raw key strings the wrapper actually sends to redis-py.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + self.store: dict[str, Any] = {} + + async def get(self, name: str) -> Any: + self.calls.append(("get", (name,))) + return self.store.get(name) + + async def set(self, name: str, value: Any, **kwargs: Any) -> bool: + self.calls.append(("set", (name, value))) + self.store[name] = value + return True + + async def xadd(self, name: str, fields: Any, **kwargs: Any) -> str: + self.calls.append(("xadd", (name,))) + return "1-0" + + async def xread(self, streams: dict[str, Any], **kwargs: Any) -> list[Any]: + self.calls.append(("xread", tuple(streams))) + return [] + + async def xgroup_create(self, name: str, groupname: str, **kwargs: Any) -> bool: + self.calls.append(("xgroup_create", (name, groupname))) + return True + + async def xreadgroup(self, groupname: str, consumername: str, streams: dict, **kw: Any): + self.calls.append(("xreadgroup", (groupname, *streams))) + return [] + + async def xack(self, name: str, groupname: str, *ids: str) -> int: + self.calls.append(("xack", (name, groupname))) + return len(ids) + + async def publish(self, channel: str, message: Any) -> int: + self.calls.append(("publish", (channel,))) + return 1 + + async def scan_iter(self, match: str | None = None, count: int = 10): + self.calls.append(("scan_iter", (match or "",))) + for name in list(self.store): + yield name + + def pipeline(self, transaction: bool = False) -> _FakePipeline: + return _FakePipeline(self) + + @property + def sent_keys(self) -> list[str]: + return [arg for _, args in self.calls for arg in args if isinstance(arg, str)] + + +class _FakePipeline: + def __init__(self, parent: _FakeRedis) -> None: + self.parent = parent + + def hincrbyfloat(self, name: str, field: str, amount: float) -> None: + self.parent.calls.append(("hincrbyfloat", (name,))) + + def hincrby(self, name: str, field: str, amount: int = 1) -> None: + self.parent.calls.append(("hincrby", (name,))) + + def expire(self, name: str, seconds: int) -> None: + self.parent.calls.append(("expire", (name,))) + + async def execute(self) -> list[Any]: + return [] + + +# --------------------------------------------------------------------------- +# 1. The key builder always prefixes. +# --------------------------------------------------------------------------- + +def test_key_carries_the_tenant_prefix() -> None: + with organization_scope(ORG_A): + assert key("activity").value == f"{KEY_ROOT}:{ORG_A}:activity" + assert key("room", "thread-1").value == f"{KEY_ROOT}:{ORG_A}:room:thread-1" + # Nesting is parts, not a colon in the namespace — the shape + # `cc::activity:live:` still comes out right. + assert key("activity", "live", "run-9").value == f"{KEY_ROOT}:{ORG_A}:activity:live:run-9" + + +@pytest.mark.parametrize( + "namespace", + # Every namespace in the tree today (activity.py, room_stream.py, + # stream_relay.py, steer.py) — the ones this wrapper must be able to express. + [ + "activity", "cost", "room", "presence", "stream", "active", + "runactor", "runsource", "runfloor", "control", "ctrl-ack", "steer", + ], +) +def test_every_existing_namespace_round_trips_prefixed(namespace: str) -> None: + with organization_scope(ORG_A): + assert key(namespace, "x").value.startswith(f"{KEY_ROOT}:{ORG_A}:{namespace}:") + + +def test_str_of_a_key_is_the_prefixed_value() -> None: + """``f"{k}"`` must not be a way to get something shorter than ``.value``.""" + with organization_scope(ORG_A): + k = key("cost", "2026-08-08") + assert str(k) == k.value == f"{KEY_ROOT}:{ORG_A}:cost:2026-08-08" + + +# --------------------------------------------------------------------------- +# 2. No public path yields an unprefixed key. +# --------------------------------------------------------------------------- + +def test_no_public_attribute_of_a_key_is_unprefixed() -> None: + """Reflection over the whole public surface of a built key. + + Not a spot check: the point of the type is that *nothing* on it hands back a + usable-but-untenanted key, so the test enumerates rather than asserting on + the two accessors it happens to remember. "Key-shaped" is read as "contains + a ``:``" — the validated component fields (``namespace``, ``parts``, + ``organization_id``) are inputs and cannot contain one, so anything with a + colon in it is something a caller could mistake for a key. + """ + with organization_scope(ORG_A): + k = key("room", "thread-1") + prefix = f"{KEY_ROOT}:{ORG_A}:" + checked = 0 + for name in dir(k): + if name.startswith("_"): + continue + value = getattr(k, name) + if isinstance(value, str) and ":" in value: + checked += 1 + assert value.startswith(prefix), ( + f"TenantKey.{name} exposes an unprefixed key: {value!r}" + ) + assert checked, "no key-shaped attribute was inspected — the test proves nothing" + + +def test_client_rejects_a_raw_string_key() -> None: + """The choke point: no command on the client takes a key you typed.""" + client = TenantRedis(_FakeRedis()) + with organization_scope(ORG_A), pytest.raises(TypeError, match="TenantKey"): + # Deliberately the exact untenanted key that exists in production today. + client._raw("cc:activity") + + +async def test_every_client_command_sends_only_prefixed_keys() -> None: + """Exercise the client and assert on what reached redis-py, not on intent.""" + fake = _FakeRedis() + client = TenantRedis(fake) + with organization_scope(ORG_A): + await client.set(client.key("active", "t1"), "1") + await client.get(client.key("active", "t1")) + await client.xadd(client.key("stream", "t1"), {"event": "{}"}) + await client.xread({client.key("stream", "t1"): "0-0"}) + await client.publish(client.key("control", "t1"), "ping") + g = client.group("cc-ingest") + await client.xgroup_create(client.key("stream", "t1"), g, id="$", mkstream=True) + await client.xreadgroup(g, "worker-1", {client.key("stream", "t1"): ">"}) + await client.xack(client.key("stream", "t1"), g, "1-0") + pipe = client.pipeline() + pipe.hincrbyfloat(client.key("cost", "2026-08-08"), "total|cost", 1.0) + pipe.expire(client.key("cost", "2026-08-08"), 60) + await pipe.execute() + + prefix = f"{KEY_ROOT}:{ORG_A}:" + key_like = [s for s in fake.sent_keys if s.startswith(KEY_ROOT) or ":" in s] + assert key_like, "the fake recorded nothing — the test proves nothing" + for sent in key_like: + # Group names are `:`, not keys; everything else must be a key. + assert sent.startswith(prefix) or sent.endswith(f":{ORG_A}"), ( + f"an unprefixed value reached redis-py: {sent!r}" + ) + + +async def test_scan_pattern_cannot_escape_the_tenant() -> None: + fake = _FakeRedis() + client = TenantRedis(fake) + with organization_scope(ORG_A): + pattern = client.match("active") + assert pattern.value == f"{KEY_ROOT}:{ORG_A}:active:*" + async for _ in client.scan_iter(pattern): + pass + with pytest.raises(TypeError, match="ScanPattern"): + async for _ in client.scan_iter("cc:active:*"): # type: ignore[arg-type] + pass + assert ("scan_iter", (f"{KEY_ROOT}:{ORG_A}:active:*",)) in fake.calls + + +def test_glob_metacharacters_in_a_part_stay_literal() -> None: + """A ``*`` in a value must not widen the pattern past this tenant's data.""" + with organization_scope(ORG_A): + pattern = match("room", "thread*1") + assert pattern.value == f"{KEY_ROOT}:{ORG_A}:room:thread\\*1:*" + + +def test_namespace_cannot_smuggle_a_colon() -> None: + """`namespace="activity:live"` would let a caller hand-shape segments.""" + with organization_scope(ORG_A): + with pytest.raises(ValueError, match="namespace"): + key("activity:live", "run-1") + with pytest.raises(ValueError, match="namespace"): + key("cc:activity") + + +def test_organization_id_cannot_smuggle_a_colon_or_glob() -> None: + for bad in ("org:evil", "org*", "", "org id", "a" * 65): + with pytest.raises(ValueError, match="organization_id"), organization_scope(ORG_A): + TenantKey(bad, "room", ("t1",)) + + +def test_empty_or_whitespace_parts_are_rejected() -> None: + """An empty part would collapse to ``cc::room:`` — a shared key.""" + with organization_scope(ORG_A): + with pytest.raises(ValueError): + key("room", "") + with pytest.raises(ValueError): + key("room", " t1 ") + with pytest.raises(TypeError): + key("room", 7) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# 3. Unbound tenant raises — never a silent global key. +# --------------------------------------------------------------------------- + +def test_unbound_key_build_raises() -> None: + assert organization_bound() is False + with pytest.raises(TenantNotBound): + key("activity") + + +def test_unbound_current_organization_raises() -> None: + with pytest.raises(TenantNotBound): + current_organization() + + +def test_unbound_direct_construction_raises() -> None: + """The dataclass constructor is not a way around the builder.""" + with pytest.raises(TenantNotBound): + TenantKey(ORG_A, "activity", ()) + with pytest.raises(TenantNotBound): + ConsumerGroup(ORG_A, "cc-ingest") + with pytest.raises(TenantNotBound): + ScanPattern(ORG_A, "active", (), "*") + + +def test_unbound_group_and_match_raise() -> None: + with pytest.raises(TenantNotBound): + group("cc-ingest") + with pytest.raises(TenantNotBound): + match("active") + + +async def test_unbound_command_raises_even_with_a_key_in_hand() -> None: + """A key captured under a binding is not usable after it is released.""" + client = TenantRedis(_FakeRedis()) + with organization_scope(ORG_A): + captured = key("room", "t1") + with pytest.raises(TenantNotBound): + await client.get(captured) + + +def test_binding_is_released_even_when_the_block_raises() -> None: + with pytest.raises(RuntimeError), organization_scope(ORG_A): + raise RuntimeError("boom") + assert organization_bound() is False + + +def test_release_survives_a_foreign_token() -> None: + """Fail closed on a cross-context reset, exactly like MT-0a's release.""" + tr.bind_organization(ORG_A) + tr.release_organization(tr._ORGANIZATION_ID.set(ORG_B)) + tr.release_organization(None) # no-op, must not raise + tr._ORGANIZATION_ID.set(None) + + +# --------------------------------------------------------------------------- +# 4. Two tenants are disjoint. +# --------------------------------------------------------------------------- + +def test_two_tenants_produce_disjoint_keys() -> None: + with organization_scope(ORG_A): + a = {key(ns, "same-thread-id").value for ns in ("room", "stream", "active", "cost")} + with organization_scope(ORG_B): + b = {key(ns, "same-thread-id").value for ns in ("room", "stream", "active", "cost")} + # Same thread id, same namespaces — and no key in common. Before this + # module, every one of these pairs was the SAME key. + assert a and b and not (a & b) + + +def test_two_tenants_get_disjoint_consumer_groups() -> None: + """§1.9: 'separate consumer groups per tenant on the Streams bus'.""" + with organization_scope(ORG_A): + a = group("cc-ingest").value + with organization_scope(ORG_B): + b = group("cc-ingest").value + assert a != b + assert a.endswith(ORG_A) and b.endswith(ORG_B) + + +def test_two_tenants_get_disjoint_scan_patterns() -> None: + with organization_scope(ORG_A): + a = match("active").value + with organization_scope(ORG_B): + b = match("active").value + assert a != b + # Neither pattern can match the other tenant's keys. + assert ORG_B not in a and ORG_A not in b + + +async def test_a_key_from_another_tenant_is_refused_at_command_time() -> None: + """The realistic leak: a key that outlives its binding onto another task.""" + client = TenantRedis(_FakeRedis()) + with organization_scope(ORG_A): + stolen = key("room", "t1") + with organization_scope(ORG_B), pytest.raises(TenantMismatch): + await client.get(stolen) + + +async def test_two_tenants_do_not_see_each_other_through_the_client() -> None: + fake = _FakeRedis() + client = TenantRedis(fake) + with organization_scope(ORG_A): + await client.set(client.key("room", "t1"), "A") + with organization_scope(ORG_B): + await client.set(client.key("room", "t1"), "B") + assert await client.get(client.key("room", "t1")) == "B" + with organization_scope(ORG_A): + assert await client.get(client.key("room", "t1")) == "A" + + +# --------------------------------------------------------------------------- +# 5. The client has no proxy hole. +# --------------------------------------------------------------------------- + +def test_client_does_not_proxy_unknown_attributes() -> None: + """``__getattr__`` forwarding would hand redis-py a raw key string. + + The command surface is enumerated on purpose (see the class docstring); this + pins that decision, because adding a two-line ``__getattr__`` later would + silently reopen every hole the rest of this file closes. + """ + client = TenantRedis(_FakeRedis()) + assert not hasattr(TenantRedis, "__getattr__") + assert not hasattr(client, "keys") + assert not hasattr(client, "flushdb") + # __slots__ also stops a caller from stashing the raw client back on. + with pytest.raises(AttributeError): + client.raw = object() # type: ignore[attr-defined] + + +def test_every_client_command_takes_a_typed_key_first() -> None: + """No public command may take a plain ``str`` in the key position.""" + offenders: list[str] = [] + for name, fn in inspect.getmembers(TenantRedis, callable): + if name.startswith("_") or name in {"key", "group", "match", "pipeline"}: + continue + params = [p for p in inspect.signature(fn).parameters.values() if p.name != "self"] + if not params: + continue + first = params[0] + annotation = str(first.annotation) + if "TenantKey" not in annotation and "ConsumerGroup" not in annotation \ + and "ScanPattern" not in annotation and "Mapping" not in annotation: + offenders.append(f"{name}({first.name}: {annotation})") + assert not offenders, ( + "Client command(s) whose key argument is not a typed tenant key:\n " + + "\n ".join(offenders) + ) + + +# --------------------------------------------------------------------------- +# 6. The ratchet — a direct redis client outside the wrapper fails the build. +# --------------------------------------------------------------------------- +# +# The ticket's done-when. Source-level on purpose, for the same reason +# ``test_db_engine_seam.py`` is: the failure mode is a NEW module reaching for +# redis-py directly, which no runtime assertion sees until that module is in +# production holding another tenant's key. +# +# MT-1e deliberately migrates nothing — converting ~20 call sites with their own +# TTL and liveness semantics is a separate, riskier change. So the allow-list +# below IS the follow-up ticket's checklist. Each entry names what must move. +# Delete the entry when the module is converted; `test_no_stale_allowlist_entries` +# fails if you leave a dead one behind, so the list can only shrink. + +_ALLOWED_DIRECT_REDIS: dict[str, str] = { + "packages/acb_common/acb_common/tenant_redis.py": + "the wrapper itself — the one place redis-py is reached", + + # ── MT-1e follow-up: convert these to acb_common.tenant_redis ── + "packages/acb_common/acb_common/activity.py": + "FOLLOW-UP: cc:activity, cc:activity:live:{run_id}, cc:cost:{day}; pooled " + "client at :66 plus two one-shot clients at :226/:317", + "apps/services/orchestrator/orchestrator/stream_relay.py": + "FOLLOW-UP: cc:stream, cc:active, cc:runactor, cc:runsource, cc:runfloor, " + "cc:control, cc:ctrl-ack — 31 key call sites, the largest conversion", + "apps/services/gateway/gateway/room_stream.py": + "FOLLOW-UP: cc:room:{tid} stream + cc:presence:{tid} hash", + "apps/services/gateway/gateway/routes/chat.py": + "FOLLOW-UP: inline client at :700 and the cc:active:* SCAN at :707 — the " + "scan is a cross-tenant enumeration the moment a second tenant exists", + "apps/services/gateway/gateway/routes/email/core.py": + "FOLLOW-UP: _get_redis() at :120 backing the email:att:cache:* keys", + "apps/services/ingestion/ingestion/queue.py": + "FOLLOW-UP: ingestion:{clickup,zoho,gmail,dlq} streams (sync client)", + "apps/services/ingestion/ingestion/consumer.py": + "FOLLOW-UP: same streams read side + the SHARED 'cc-ingest' consumer group " + "at :95 — §1.9 requires a group per tenant", +} + + +def _python_files() -> list[Path]: + roots = [_REPO / "apps", _REPO / "packages"] + out: list[Path] = [] + for root in roots: + out.extend( + p for p in root.rglob("*.py") + if "__pycache__" not in p.parts and ".venv" not in p.parts + ) + return out + + +def _imports_redis_package(path: Path) -> bool: + """True if the file imports the ``redis`` package itself. + + Parsed rather than grepped, exactly as ``test_db_engine_seam.py`` explains: + half these modules discuss Redis in prose, and a substring match would read + the documentation as a violation. Import-based rather than call-based + because every way to build a client (``from_url``, ``Redis()``, + ``ConnectionPool``) starts with importing the package, so one check covers + them all — and ``agent_framework.redis`` correctly does not match, since its + root module is not ``redis``. + """ + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import) and any( + a.name.split(".")[0] == "redis" for a in node.names + ): + return True + if ( + isinstance(node, ast.ImportFrom) + and node.level == 0 + and (node.module or "").split(".")[0] == "redis" + ): + return True + return False + + +def _rel(path: Path) -> str: + return str(path.relative_to(_REPO)).replace("\\", "/") + + +def test_no_direct_redis_client_outside_the_wrapper() -> None: + offenders = sorted(_rel(p) for p in _python_files() if _imports_redis_package(p)) + unexpected = [p for p in offenders if p not in _ALLOWED_DIRECT_REDIS] + assert not unexpected, ( + "New direct redis-py import(s) — Redis is reached only through the tenant " + "wrapper (saas_multitenancy.md §0.9.4, MT-1e):\n " + + "\n ".join(unexpected) + + "\n\nUse acb_common.tenant_redis.get_tenant_redis() and build keys with " + "key(namespace, *parts). A hand-written key string carries no tenant, and " + "Redis has no row-level policy behind it to catch that." + ) + + +def test_no_stale_allowlist_entries() -> None: + """A converted module must leave the list, so the list can only shrink. + + Without this the allow-list drifts into blanket permission, and the next + reader cannot tell which entries still describe real un-migrated code. + """ + offenders = {_rel(p) for p in _python_files() if _imports_redis_package(p)} + stale = sorted(set(_ALLOWED_DIRECT_REDIS) - offenders) + assert not stale, f"Allow-list entries that no longer import redis: {stale}" + + +def test_allowlist_entries_say_why() -> None: + """Every exemption carries a reason, and every follow-up says FOLLOW-UP.""" + for path, reason in _ALLOWED_DIRECT_REDIS.items(): + assert len(reason) > 20, f"{path} has no real reason recorded" + follow_ups = [p for p, r in _ALLOWED_DIRECT_REDIS.items() if r.startswith("FOLLOW-UP")] + assert len(follow_ups) == len(_ALLOWED_DIRECT_REDIS) - 1, ( + "Exactly one entry (the wrapper) may be a permanent exemption; every other " + "entry is an un-migrated call site and must be marked FOLLOW-UP." + ) + + +# --------------------------------------------------------------------------- +# 7. The second ratchet — no hand-written ``cc:`` key literal. +# --------------------------------------------------------------------------- +# +# The import ratchet above catches a module that opens its own connection. It +# does NOT catch a module that builds an untenanted key and hands it to someone +# else's client — which is exactly what ``steer.py`` does today (``cc:steer`` +# keys, ``stream_relay``'s client) and what ``chat.py`` does with its +# ``cc:active:*`` SCAN. Those are the same leak with an extra hop, so they get +# their own ratchet and their own follow-up entries. + +_ALLOWED_CC_LITERALS: dict[str, str] = { + "packages/acb_common/acb_common/activity.py": + "FOLLOW-UP: ACTIVITY_STREAM / LIVE_PREFIX / COST_PREFIX (:45-47)", + "apps/services/orchestrator/orchestrator/stream_relay.py": + "FOLLOW-UP: seven PREFIX constants (:53, :54, :65, :71, :76, :557, :558)", + "apps/services/orchestrator/orchestrator/steer.py": + "FOLLOW-UP: STEER_PREFIX (:55); borrows stream_relay's client, so the " + "import ratchet cannot see it", + "apps/services/gateway/gateway/room_stream.py": + "FOLLOW-UP: ROOM_STREAM_PREFIX / PRESENCE_PREFIX (:39-40)", + "apps/services/gateway/gateway/routes/chat.py": + "FOLLOW-UP: the cc:active:* SCAN match and removeprefix (:707-711) — a " + "cross-tenant session enumeration once a second tenant exists", +} + + +def _cc_key_literals(path: Path) -> list[str]: + """String constants that ARE a ``cc:`` key, excluding docstrings. + + Docstrings are skipped by node identity rather than by heuristic: a module + that explains its keys in prose is documenting, not constructing. A literal + that *starts* with ``cc:`` in any other position is a key being built. + """ + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + docstrings = { + id(node.body[0].value) + for node in ast.walk(tree) + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + and node.body + and isinstance(node.body[0], ast.Expr) + and isinstance(node.body[0].value, ast.Constant) + and isinstance(node.body[0].value.value, str) + } + return [ + n.value for n in ast.walk(tree) + if isinstance(n, ast.Constant) + and isinstance(n.value, str) + and n.value.startswith(f"{KEY_ROOT}:") + and id(n) not in docstrings + ] + + +def test_no_hand_written_cc_key_literals() -> None: + offenders = sorted(_rel(p) for p in _python_files() if _cc_key_literals(p)) + unexpected = [p for p in offenders if p not in _ALLOWED_CC_LITERALS] + assert not unexpected, ( + "Hand-written 'cc:' key literal(s) — keys are built by " + "acb_common.tenant_redis.key(namespace, *parts), which cannot omit the " + "tenant (saas_multitenancy.md §0.9.4, MT-1e):\n " + "\n ".join(unexpected) + ) + + +def test_no_stale_cc_literal_allowlist_entries() -> None: + offenders = {_rel(p) for p in _python_files() if _cc_key_literals(p)} + stale = sorted(set(_ALLOWED_CC_LITERALS) - offenders) + assert not stale, f"Allow-list entries that no longer build a 'cc:' key: {stale}" + + +def test_the_cc_root_is_written_in_exactly_one_place() -> None: + """Nothing but the wrapper may define the ``cc:`` root for a NEW key. + + Narrower than it looks and deliberately so: this pins that ``KEY_ROOT`` is + the only literal the wrapper itself uses to build a key, so a future edit + cannot quietly add a second, unprefixed builder beside it. + """ + source = (_REPO / "packages/acb_common/acb_common/tenant_redis.py").read_text() + tree = ast.parse(source) + literals = [ + n.value for n in ast.walk(tree) + if isinstance(n, ast.Constant) and isinstance(n.value, str) and n.value.startswith("cc:") + ] + assert literals == [], ( + f"tenant_redis.py contains a hand-written 'cc:' key literal: {literals}. " + "The root belongs in KEY_ROOT and nowhere else." + ) diff --git a/tests/unit/test_tenant_session.py b/tests/unit/test_tenant_session.py new file mode 100644 index 000000000..b8b124bb5 --- /dev/null +++ b/tests/unit/test_tenant_session.py @@ -0,0 +1,179 @@ +"""MT-1c — the tenant binding seam. ``SET LOCAL``, in a transaction, or refuse. + +Spec: ``ai-company-brain/specs/saas_multitenancy.md`` §0.1 / §1.3 · WS-29 · D15. + +RLS enforces isolation server-side, but only against a session that has told the +server which tenant it acts for. ``tenant_session`` is the one place that +happens — which is what makes a pooled data plane defensible across **ten** +connection paths (§0.1, an inventory that has itself been wrong twice). + +Three properties, each with a specific way of going wrong: + +1. **``SET LOCAL``, never ``SET``.** The engine pools connections. A + session-scoped ``SET`` survives the connection's return to the pool, so the + next borrower — different request, different customer — inherits it. This is + the highest-consequence line in the migration and it is asserted as a string, + because the difference is one word and the symptom is a cross-tenant read + under concurrency that no unit test would reproduce by accident. +2. **Inside a real transaction.** ``SET LOCAL`` outside one is a *silent no-op*: + Postgres warns, the policy sees an unset GUC, every query returns nothing. + That reads as "the feature is broken", not "tenancy is broken". +3. **Unbound refuses.** Never "the usual tenant". +""" +from __future__ import annotations + +import inspect + +import pytest +from acb_common.db import ( + TenantUnbound, + bind_tenant, + current_tenant, + release_tenant, + tenant_session, +) + +_ORG_A = "11111111-1111-1111-1111-111111111111" +_ORG_B = "22222222-2222-2222-2222-222222222222" + + +class _FakeSession: + """Records the order of operations, which is what these tests are about.""" + + def __init__(self, calls: list): + self.calls = calls + + async def begin(self): + self.calls.append(("begin", None)) + + async def execute(self, stmt, params=None): + self.calls.append(("execute", (str(stmt), params))) + + async def commit(self): + self.calls.append(("commit", None)) + + async def rollback(self): + self.calls.append(("rollback", None)) + + async def close(self): + self.calls.append(("close", None)) + + +@pytest.fixture +def calls(monkeypatch): + seen: list = [] + monkeypatch.setattr("acb_common.db.get_session_factory", + lambda: (lambda: _FakeSession(seen)), raising=True) + return seen + + +# ========================================================================== +# 1 — SET LOCAL, not SET. +# ========================================================================== +def test_the_seam_uses_set_local_not_set() -> None: + """A string assertion, deliberately. + + ``SET`` vs ``SET LOCAL`` is a one-word difference whose only symptom is a + cross-tenant read under connection reuse. No behavioural unit test + reproduces that reliably, so the source is pinned instead. + """ + src = inspect.getsource(tenant_session.__wrapped__) # type: ignore[attr-defined] + assert "SET LOCAL app.tenant_id" in src + assert "SET app.tenant_id" not in src.replace("SET LOCAL app.tenant_id", ""), ( + "a session-scoped SET survives the connection's return to the pool — " + "the next borrower reads the previous tenant" + ) + + +@pytest.mark.asyncio +async def test_binding_is_issued_as_a_bound_parameter(calls) -> None: + async with tenant_session(_ORG_A): + pass + sets = [c for c in calls if c[0] == "execute"] + assert len(sets) == 1 + sql, params = sets[0][1] + assert "SET LOCAL app.tenant_id" in sql + assert params == {"tenant": _ORG_A} + assert _ORG_A not in sql, "the tenant id was interpolated into the statement" + + +# ========================================================================== +# 2 — inside a transaction, or the SET LOCAL is a silent no-op. +# ========================================================================== +@pytest.mark.asyncio +async def test_begin_precedes_the_set_local(calls) -> None: + async with tenant_session(_ORG_A): + pass + names = [c[0] for c in calls] + assert names.index("begin") < names.index("execute"), ( + "SET LOCAL outside a transaction is a silent no-op — every query then " + "returns zero rows and it looks like a broken feature" + ) + assert names == ["begin", "execute", "commit", "close"] + + +@pytest.mark.asyncio +async def test_failure_rolls_back_and_closes(calls) -> None: + with pytest.raises(ValueError): + async with tenant_session(_ORG_A): + raise ValueError("boom") + names = [c[0] for c in calls] + assert "rollback" in names and names[-1] == "close" + assert "commit" not in names + + +# ========================================================================== +# 3 — unbound refuses, and never defaults. +# ========================================================================== +@pytest.mark.asyncio +async def test_unbound_refuses(calls) -> None: + with pytest.raises(TenantUnbound): + async with tenant_session(): + pass + assert calls == [], "a session was opened before the tenant check" + + +@pytest.mark.asyncio +async def test_context_binding_is_used_when_no_argument_given(calls) -> None: + token = bind_tenant(_ORG_A) + try: + assert current_tenant() == _ORG_A + async with tenant_session(): + pass + finally: + release_tenant(token) + _, params = next(c for c in calls if c[0] == "execute")[1] + assert params == {"tenant": _ORG_A} + assert current_tenant() is None + + +@pytest.mark.asyncio +async def test_explicit_argument_overrides_the_context(calls) -> None: + """A job binding its own org must win over whatever ambient context it + inherited from the process that queued it.""" + token = bind_tenant(_ORG_A) + try: + async with tenant_session(_ORG_B): + pass + finally: + release_tenant(token) + _, params = next(c for c in calls if c[0] == "execute")[1] + assert params == {"tenant": _ORG_B} + + +def test_release_is_null_safe_and_idempotent() -> None: + release_tenant(None) + tok = bind_tenant(_ORG_A) + release_tenant(tok) + release_tenant(tok) + assert current_tenant() is None + + +def test_get_db_is_documented_as_not_tenant_bound() -> None: + """~200 call sites still use it. The docstring is the only thing standing + between a reader and the assumption that it is safe under RLS.""" + from acb_common.db import get_db + + doc = (get_db.__doc__ or "") + assert "Not tenant-bound" in doc + assert "tenant_session" in doc diff --git a/tests/unit/test_tool_schema_diet.py b/tests/unit/test_tool_schema_diet.py index 2419ab7f6..f04d55de4 100644 --- a/tests/unit/test_tool_schema_diet.py +++ b/tests/unit/test_tool_schema_diet.py @@ -70,7 +70,14 @@ 'load_design_system': {'params': {'section': 'string'}, 'required': []}, 'manage_todo_list': {'params': {'todoList': 'string'}, 'required': ['todoList']}, - 'query_history': {'params': {'query': 'string'}, 'required': ['query']}, + # MT-0c-1 (D16): query_history no longer takes SQL. The contract change is + # deliberate — a model-composed `query` string was a raw-SQL tool, which + # saas_multitenancy.md §0.9.3 makes a condition on the pooled decision. All + # criteria are optional: "recall recent conversation" is a valid call. + 'query_history': {'params': {'agent_name': 'string', 'limit': 'integer', + 'search': 'string', 'since_days': 'integer', + 'thread_id': 'string'}, + 'required': []}, 'recall_agent': {'params': {'query': 'string'}, 'required': ['query']}, 'recall_notes': {'params': {'path': 'string', 'query': 'string'}, 'required': ['path']}, diff --git a/workbench/AGENTS.md b/workbench/AGENTS.md index 542535fe1..6f1ca7016 100644 --- a/workbench/AGENTS.md +++ b/workbench/AGENTS.md @@ -97,8 +97,11 @@ Control Plane (Next.js browser UI) and local development tools. ## Identity — the BFF is not optional, it is where identity comes from -Contract: `ai-company-brain/specs/user_management_contract.md`. Two rules bind -every page in this app. +Contract: `ai-company-brain/specs/user_management_contract.md` (**eleven** rules +since 2026-08-08). Two rules bind every page in this app — and a third arrives +with WS-29: **R11, never take the acting tenant from input.** The tenant rides +the authenticated session; a subdomain is a *lookup to be verified against the +session*, never an assertion to be trusted. See `saas_multitenancy.md` §1.5. **Never point the browser at the gateway.** A top-level navigation to `api.commandcenter.fracktal.in` carries **no** Bearer and **no** `X-User-Email`