docs(tokens): Game Center token layer implementation plan (03) - handoff-ready

Design fan-out (cto-architect) + primary-source compliance verification
(lead-sttil) + independent adversarial audit (code-auditor, 2 HIGH findings
fixed: registry test fixture eager-delete, offboarding purge table list vs
real schema.sql). Executable by a fresh Sonnet/Opus session. Token mode
ships dark; K1-K6 decisions pending Kisa.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kisa 2026-07-07 05:49:17 -04:00
parent f28b5bf39e
commit 03afd62caf

View file

@ -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 P2P5 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 P2P5 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 (K1K6 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 (K1K6 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.
---