diff --git a/docs/build-plans/03-game-center-token-layer.md b/docs/build-plans/03-game-center-token-layer.md index 226d8c6..9b58a9c 100644 --- a/docs/build-plans/03-game-center-token-layer.md +++ b/docs/build-plans/03-game-center-token-layer.md @@ -20,10 +20,10 @@ ### Execution preconditions — check ALL before Task 1 -- [ ] **The in-flight readiness lane has landed.** As of 2026-07-07 the signal repo has uncommitted WIP (`python-backend/api/main.py`, `core/worklist_readiness.py`, untracked `core/rollup.py`, `tests/test_rollup.py`) belonging to the readiness-model P2–P5 lane (`docs/build-plans/02-readiness-model-completion.md`). Run `git status --short` — if `python-backend/` is not clean, STOP and confirm with Kisa before proceeding (decision K5 below). Both lanes edit `normalizer.py`/`main.py`; sequence, don't merge blind. +- [ ] **The in-flight readiness lane has landed.** As of 2026-07-07 the readiness-model P2–P5 lane (`docs/build-plans/02-readiness-model-completion.md`) is actively committing to this repo; the exact dirty-file set changes hour to hour, so trust `git status --short`, not any file list written here. If `python-backend/` is not clean, STOP and confirm with Kisa before proceeding (decision K5 below). Both lanes edit `normalizer.py`/`main.py`; sequence, don't merge blind. - [ ] `cd /Users/sttil-solutions/projects/signal && git pull --rebase` succeeds and `context/current-state.md` has been read. - [ ] Full test suite green before you start: `python -m pytest tests/ -v` (132+ tests as of 2026-07-07). Record the pass count; you must not lower it. -- [ ] Kisa's pending decisions (K1–K6 below) checked in `context/current-state.md` Decisions section. This plan implements the recommended defaults; if Kisa reversed any, adapt before building. +- [ ] Kisa's pending decisions (K1–K6 below) checked in `context/current-state.md` Decisions section — a confirming or reversing line will appear there once she rules; absent means still pending. This plan implements the recommended defaults, and everything ships DARK (default-off), so building ahead of the rulings is safe: the code is inert until `IDENTITY_MODE=token` is set, and no K-decision is irreversible in code. If Kisa reversed any, adapt before building. K1 note: `context/current-state.md` Stack still records the older "rotating RAP-ID/CSP-ID" direction — that conflict was surfaced to Kisa 2026-07-07 with this plan; do not treat the Stack line as a K1 ruling. ### Kisa decision register (defaults this plan implements) @@ -62,7 +62,7 @@ python-backend/core/tokens/mint.py CREATE Task 2 python-backend/core/tokens/registry.py CREATE Task 3 supabase/migrations/20260708000001_token_registry.sql CREATE Task 3 python-backend/core/audit_logger.py MODIFY (2 enum lines) Task 4 -python-backend/api/main.py MODIFY (mint endpoint; upload guard; 2 audit spots) Tasks 4, 6, 8 +python-backend/api/main.py MODIFY (mint endpoint; upload guard; 2 audit spots; revoke endpoint) Tasks 4, 6, 7, 8 python-backend/api/normalizer.py MODIFY (token mode) Task 5 docs/onboarding/crosswalk-template.csv CREATE Task 9 docs/onboarding/token-crosswalk-runbook.md CREATE Task 9 @@ -406,14 +406,19 @@ class FakeTable: return self def delete(self): - remaining, removed = [], [] - for r in self.store.get(self.name, []): - (removed if all(r.get(k) == v for k, v in self._filters.items()) else remaining).append(r) - self.store[self.name] = remaining - self._result = FakeResponse(removed) + # Defer to execute(): the real client chains .delete().eq(...).execute(), + # so filters are not yet set when delete() is called. + self._pending_delete = True return self def execute(self): + if getattr(self, "_pending_delete", False): + removed = [r for r in self.store.get(self.name, []) + if all(r.get(k) == v for k, v in self._filters.items())] + self.store[self.name] = [ + r for r in self.store.get(self.name, []) if r not in removed + ] + return FakeResponse(removed) return getattr(self, "_result", FakeResponse([])) @@ -475,18 +480,19 @@ violation, not a feature. from core.supabase_client import get_client -# Org-scoped operational tables purged on offboarding, verified against -# persistence.py/audit_logger.py table usage 2026-07-07. audit_log is -# intentionally EXCLUDED (WORM — the tombstone lives there). +# Tables purged on offboarding. Verified against python-backend/db/schema.sql +# 2026-07-07: ONLY these carry an org_id column. Child tables purge via +# cascade — upload_batches cascades to source_files/normalized_records/ +# mapping_decisions/raw_rows; report_runs cascades to export_files/ +# report_items. Do NOT add a table here unless schema.sql shows it has +# org_id (a delete().eq("org_id", ...) on a table without that column +# raises, and offboarding would report failure on every call). +# audit_log is intentionally EXCLUDED (WORM — the tombstone lives there). ORG_SCOPED_PURGE_TABLES = [ "tokens", "token_mint_batches", - "normalized_records", - "mapping_decisions", - "report_runs", "upload_batches", - "source_files", - "export_files", + "report_runs", "confirmed_visits", "doc_status", ] @@ -536,7 +542,7 @@ def revoke_org(org_id: str) -> dict[str, int]: return counts ``` -Note: some operational tables key rows by `batch_id`/`report_run_id` rather than `org_id` directly. After writing this, run `grep -n "org_id" python-backend/core/persistence.py` and check each table in `ORG_SCOPED_PURGE_TABLES` actually has an `org_id` column in `supabase/migrations/`; remove any that don't and instead delete them via their parent's cascade (the migration FKs use `on delete cascade` where that holds). Record what you found in the Task 3 commit message. +Note: the operational DDL lives in `python-backend/db/schema.sql` (NOT in `supabase/migrations/`, which holds only two later migrations). Before running the registry tests, re-verify each table in `ORG_SCOPED_PURGE_TABLES` still has an `org_id` column there (`grep -n "org_id" python-backend/db/schema.sql`), and record the verification in the Task 3 commit message. IMPORTANT: the unit tests use a fake client and CANNOT catch a wrong table/column name — Task 11 includes the live check that a real `revoke_org` returns a clean receipt (no `-1` values). - [ ] **Step 5: Run tests to verify they pass** @@ -656,7 +662,7 @@ Expected: FAIL with 404 on `/api/token/mint` (route doesn't exist) from pydantic import BaseModel, ConfigDict, Field ``` -(If the installed pydantic is v1 — check with `python -c "import pydantic; print(pydantic.VERSION)"` — use `class Config: extra = "forbid"` inside the model instead of `model_config`.) +(Installed pydantic verified 2.12.5 on 2026-07-07, so `ConfigDict` is correct.) (b) Add near the other request models (e.g., after `class ExportRequest`, line ~891): @@ -963,7 +969,7 @@ Note: the all-minted happy path continues past the guard into Supabase-backed pe Run: `python -m pytest tests/test_upload_token_guard.py -v` Expected: FAIL — uploads proceed (200) or fail later, instead of 422/503 at the guard -- [ ] **Step 3: Implement** — in `python-backend/api/main.py`, inside `upload_csv`, immediately AFTER the `org_id = get_or_create_org(clerk_org_id=clerk_org_id)` line and BEFORE `confirmed_visits = ...`: +- [ ] **Step 3: Implement** — in `python-backend/api/main.py`, inside `upload_csv`. Anchor precisely: `org_id = get_or_create_org(...)` appears in THREE handlers; the correct spot is the one inside `upload_csv`, identified by the unique line that follows it — insert this block immediately BEFORE `confirmed_visits = load_confirmed_visits_for_org(org_id) if org_id else {}`: ```python # Game Center guard (K6, sole minter): in token mode with enforcement on, @@ -1023,7 +1029,7 @@ git commit -m "feat(tokens): registry-enforcement upload guard - fail-closed, so **Files:** Modify `python-backend/api/main.py` (2 spots). -`signal/CLAUDE.md` rule: "All logs use hashed patient_id, never raw." Two audit calls currently embed a raw 8-char identifier prefix. Fix them unconditionally (strictly more compliant in both modes). +`signal/CLAUDE.md` rule: "All logs use hashed patient_id, never raw." Two audit calls embed a raw 8-char identifier prefix in `resource_id`. Honest framing: `build_audit_entry` already SHA-256s `resource_id` into `resource_hash` before any sink, so this is NOT an active raw-identifier leak — the fix is defense in depth (a low-entropy 8-char prefix inside the hashed string weakens it against brute-forcing, and pre-hashing keeps every code path consistent with the house rule). Apply unconditionally. - [ ] **Step 1: Find both spots** @@ -1292,7 +1298,7 @@ Before running, check `calculate_batch`'s return shape (`grep -n "def calculate_ - [ ] **Step 2: Run** `python -m pytest tests/test_token_pipeline_e2e.py -v` — expected: 2 passed. -- [ ] **Step 3: Full suite + count check** — `python -m pytest tests/ -v` — expected: precondition count + ~23 new tests, all green. +- [ ] **Step 3: Full suite + count check** — `python -m pytest tests/ -v` — expected: precondition count + ~30 new tests (7 format, 4 mint, 3 registry, 7 endpoint, 5 normalizer, 2 guard, 2 e2e), all green. - [ ] **Step 4: Commit** @@ -1307,10 +1313,11 @@ git commit -m "test(tokens): pipeline e2e - tokens traverse scoring unchanged, r Token mode ships default-OFF (K4): deploying this code changes nothing for the live Gaboro pilot until `IDENTITY_MODE=token` is set on a deployment. -- [ ] **Step 1: Deploy** — from the repo root: `railway up --detach` (code change → fresh build; `railway redeploy` only reuses the image). Then poll `railway status --json` until the active deployment shows `status: SUCCESS` and the instance is `RUNNING`. +- [ ] **Step 1: Deploy** — first confirm the build image includes the new package: check the Dockerfile / Railway build config copies `python-backend/` wholesale (it must include `core/tokens/` — `normalizer.py` now imports from it at module load, so a missing package breaks the DEFAULT pipeline too). Then from the repo root: `railway up --detach` (code change → fresh build; `railway redeploy` only reuses the image). Poll `railway status --json` until the active deployment shows `status: SUCCESS` and the instance is `RUNNING`. - [ ] **Step 2: Verify legacy behavior unchanged** — `curl -s https://signal-api-production-91c2.up.railway.app/health` returns healthy; run the existing smoke flow (`tests/smoke_test.py` if wired, or a manual legacy-CSV upload via the UI) and confirm identical results to pre-deploy. - [ ] **Step 3: Verify the mint endpoint exists and is auth-gated** — `curl -s -o /dev/null -w "%{http_code}" -X POST https://signal-api-production-91c2.up.railway.app/api/token/mint -H "Content-Type: application/json" -d '{"count": 1}'` — expected: 401/403 (auth required), NOT 404. - [ ] **Step 4: Do NOT set `IDENTITY_MODE=token` on the production service.** Token mode gets its own staging deployment or a dedicated env when the first token-mode clinic onboards (Kisa's call, K4). +- [ ] **Step 4b: Live offboarding receipt check (required — the unit fake cannot catch schema drift):** against a staging/dev deployment with a throwaway org, mint a small batch, upload one token-mode CSV, then call `POST /api/org/revoke` and verify the receipt contains NO `-1` values and the counts look sane. A `-1` means a purge-table name/column no longer matches `python-backend/db/schema.sql`. - [ ] **Step 5: Commit anything outstanding, then update the ship ledger** — add a line to `docs/ship-status-ledger.md` describing what shipped and that token mode is dark. ---