Ends the override leak: an SWO/PA save now applies to one device's line, not
every device the patient has. doc_status gains device_type ('' = legacy
patient-scoped row, applies until re-saved); the old unique key is dropped by
introspected name and replaced with the 4-col scope key. Staff overrides now
FEED THE VERDICT (device-scoped, staff wins over the CSV cell) instead of
display-only; visit overrides keep their patient-scoped fan-out via
confirmed_visits. PUT /api/doc-status and /api/confirm-visit accept an
optional echo of the patient's lines and return {lines, rollup_status}
recomputed through the same engine (stateless, no batch rebuild). Frontend
minimal fix: doc-status state and writes keyed patient:device. Independent
audit: SHIP (migration reproduced empirically on both old-key shapes; guard
scoped per its nit). 162 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
59 KiB
Game Center Token Layer — Implementation Plan (Signal v2 infrastructure)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build STTIL's patient identity tokenization layer ("Game Center") as an additive, default-off Signal v2 seam: STTIL mints cryptographically random tokens, the clinic holds the only token-to-identity crosswalk, and Signal processes tokens exactly as it processes today's opaque patient_id.
Architecture: A new isolated package python-backend/core/tokens/ (format, mint, registry) plus a blind mint endpoint whose request schema physically cannot carry identity, plus a mode-gated identity path in the CSV normalizer. The scoring pipeline (dedup, coverage, readiness, rollup, persistence) is untouched — it already treats patient_id as an opaque string and already stores only sha256(patient_id). Default behavior stays byte-identical; token mode is opt-in per deployment via env var.
Tech Stack: Python 3.11 / FastAPI / Pydantic on Railway; Supabase Postgres (service-role access, RLS enabled); pytest; Clerk JWT auth (org derived from claims).
Plan authored: 2026-07-07 by the Fable orchestrator session, from a cto-architect design fan-out + a primary-source compliance verification pass + direct repo grounding. Concept origin: STTIL-Vault/Projects/sttil-privacy-token-platform-2026-06-03.md.
0. Read this first (context for a fresh session)
What Game Center is. Every patient in any STTIL system gets a cryptographically random token. STTIL generates and stores tokens; the crosswalk between token and real identity (name, DOB, MRN) lives ONLY with the clinic/supplier. STTIL's tools process tokens and never hold the identity map. The design goal is architectural de-identification, not pseudonymization-by-contract. It upgrades Signal's privacy story and is a prerequisite for Vitality Med Tracker (see signal/CLAUDE.md "Gate: Token Layer").
The one binding constraint (never trade this away): No identity attribute may EVER cross to STTIL, including inside the token-generation request. The mint request accepts exactly one field (count: int) with extra="forbid" — identity cannot cross because there is nowhere to put it. Any future change that adds a string field to the mint path, or a per-patient "tokenize this MRN" endpoint, breaks the entire compliance thesis. Reject such changes.
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, untrackedcore/rollup.py,tests/test_rollup.py) belonging to the readiness-model P2–P5 lane (docs/build-plans/02-readiness-model-completion.md). Rungit status --short— ifpython-backend/is not clean, STOP and confirm with Kisa before proceeding (decision K5 below). Both lanes editnormalizer.py/main.py; sequence, don't merge blind. cd /Users/sttil-solutions/projects/signal && git pull --rebasesucceeds andcontext/current-state.mdhas 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.mdDecisions section. This plan implements the recommended defaults; if Kisa reversed any, adapt before building.
Kisa decision register (defaults this plan implements)
| # | Decision | Default implemented here | Status |
|---|---|---|---|
| K1 | Stable per-clinic opaque tokens (overturns the current-state.md "rotating RAP-ID/CSP-ID" note; rotation re-homed to optional v2 display aliases) | Stable | PENDING Kisa |
| K2 | Opaque TKN_ format, not memorable "BlueDragon_4821" (wordlists collapse entropy and invite mnemonic leakage; friendly names become clinic-local, STTIL never sees them) |
Opaque | PENDING Kisa |
| K3 | v1 clinic artifact = crosswalk spreadsheet template + runbook; offline mapping helper is a fast-follow | Template + runbook | PENDING Kisa |
| K4 | Token mode is opt-in per deployment; the live Gaboro pilot stays on patient_id |
Opt-in, default off | PENDING Kisa |
| K5 | Build starts only after the readiness lane lands | Sequenced after | PENDING Kisa |
| K6 | STTIL is sole minter; registry enforcement rejects non-STTIL tokens | Sole minter | PENDING Kisa |
Design rationale (alternatives, GROUNDED/GRAFTED labels, threat model) lives in section 7. Executing model: do NOT kg_add any of these — the Decision Protocol runs only when Kisa confirms.
1. Compliance guardrails (verified against primary sources 2026-07-07)
These bind every task and every future edit to this layer.
- Random, never derived. Tokens come from
secrets(CSPRNG) with zero relationship to any patient attribute. HHS guidance: a code "cannot be derived from" identity; even a keyed hash of an MRN fails. A random token passes. (45 CFR 164.514(c); HHS de-identification guidance.) - The crosswalk never exists at STTIL. No schema in STTIL's DB may hold, and no endpoint may accept, any identity attribute alongside a token. The
tokenstable has no identity columns and none may ever be added. - Dates finding (attorney-bound, do not resolve in code): full shipment dates in data flowing to STTIL break HIPAA safe-harbor de-identification regardless of token quality ("all elements of dates except year" is one of the 18 identifiers). Signal needs granular dates to function, so the viable path is expert determination (164.514(b)(1)) — a formal, documented expert engagement, not a design self-judgment. This plan does NOT truncate dates; it flags the question to the attorney brief. Nothing in this plan claims the resulting data is "de-identified."
- BA status is the attorney's call. Datavant — the $1B+ tokenization precedent — executes BAAs and accepts Business Associate status rather than claiming exemption. STTIL claims architecture facts only.
- NEVER-CLAIM list (external copy, decks, LOIs): "not a HIPAA Business Associate", "HIPAA compliant", "data is de-identified", "STTIL cannot access patient identity" (as a guarantee), "architecture eliminates PHI risk", "safe-harbor compliant", "no BAA needed", "we never hold PHI". SAFE-TO-SAY: design facts — "STTIL's system is designed so STTIL does not receive names, DOBs, SSNs, or contact info"; "the crosswalk is held only by the clinic in our design"; "tokens are cryptographically random with no relationship to any patient attribute."
- Attorney brief questions (assemble before first production use; sources and full text in section 8): 164.514(c) assignment when the vendor generates the code but the clinic builds the crosswalk; the de-identification precondition for (c); dates via expert determination; whether the current supplier patient_id contract clause is sufficient (contract ≠ cryptographic randomness — a live Signal exposure, not just a Game Center question); FTC HBNR applicability if STTIL is not HIPAA-covered; crosswalk-destruction contract language for offboarding; AWS BAA activation scope; approved pitch-deck phrasing.
2. File map
python-backend/core/tokens/__init__.py CREATE (empty package marker)
python-backend/core/tokens/format.py CREATE Task 1
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/normalizer.py MODIFY (token mode) Task 5
docs/onboarding/crosswalk-template.csv CREATE Task 9
docs/onboarding/token-crosswalk-runbook.md CREATE Task 9
tests/test_token_format.py CREATE Task 1
tests/test_token_mint.py CREATE Task 2
tests/test_token_registry.py CREATE Task 3
tests/test_token_mint_endpoint.py CREATE Task 4
tests/test_normalizer_token_mode.py CREATE Task 5
tests/test_upload_token_guard.py CREATE Task 6
tests/test_token_pipeline_e2e.py CREATE Task 10
UNCHANGED by design (verify at the end that your diff never touched them): core/dedup.py, core/coverage_calculator.py, core/worklist_readiness.py, core/readiness.py, core/rollup.py, core/persistence.py, core/doc_state_machine.py, core/rules.py. They all treat patient_id as an opaque string; a token IS an opaque string.
All test files start with the repo's standard path shim (copy exactly):
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
Run tests from the repo root: cd /Users/sttil-solutions/projects/signal && python -m pytest tests/<file> -v.
3. Database schema (Task 3 migration, shown here for reference)
-- Game Center token registry. STTIL stores ONLY opaque random tokens.
-- COMPLIANCE LOCK: no identity columns exist here and none may ever be added.
create table if not exists tokens (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references organizations(id) on delete cascade,
token text not null,
status text not null default 'active' check (status in ('active', 'revoked')),
minted_at timestamptz not null default now(),
revoked_at timestamptz,
unique (org_id, token)
);
create index if not exists tokens_org_status_idx on tokens (org_id, status);
-- Mint-event audit: counts only, never token values.
create table if not exists token_mint_batches (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references organizations(id) on delete cascade,
count int not null,
minted_by text,
created_at timestamptz not null default now()
);
alter table tokens enable row level security;
alter table token_mint_batches enable row level security;
-- No anon/public policies: the backend uses the service role, mirroring
-- existing Signal tables (see 20260607000001_audit_log_worm.sql for style).
Operational tables are untouched: persistence.py already stores sha256(patient_id); in token mode the same code path hashes the token.
Task 1: Token format (core/tokens/format.py)
Files: Create python-backend/core/tokens/__init__.py (empty), python-backend/core/tokens/format.py, tests/test_token_format.py.
- Step 1: Write the failing tests
# tests/test_token_format.py
"""
Game Center token format tests.
Canonical token: "TKN_" + 26 Crockford-Base32 chars (130 bits, CSPRNG)
+ 1 checksum char. Tokens are NEVER derived from patient attributes
(45 CFR 164.514(c): the code must not be "derived from or related to
information about the individual").
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
from core.tokens.format import (
ALPHABET,
BODY_LENGTH,
TOKEN_LENGTH,
TOKEN_PREFIX,
_checksum_char,
generate,
is_valid_token,
)
def test_alphabet_is_crockford_base32():
assert len(ALPHABET) == 32
for ambiguous in "ILOU":
assert ambiguous not in ALPHABET
def test_generate_shape():
token = generate()
assert len(token) == TOKEN_LENGTH == len(TOKEN_PREFIX) + BODY_LENGTH + 1
assert token.startswith(TOKEN_PREFIX)
assert all(c in ALPHABET for c in token[len(TOKEN_PREFIX):])
def test_generate_is_unique_across_1000():
assert len({generate() for _ in range(1000)}) == 1000
def test_valid_roundtrip():
for _ in range(100):
assert is_valid_token(generate())
def test_checksum_catches_every_single_char_substitution():
token = generate()
body_start = len(TOKEN_PREFIX)
for i in range(body_start, TOKEN_LENGTH - 1):
original = token[i]
substitute = ALPHABET[(ALPHABET.index(original) + 1) % 32]
corrupted = token[:i] + substitute + token[i + 1:]
assert not is_valid_token(corrupted), f"substitution at {i} not caught"
def test_rejects_mrn_like_values():
for bad in ("MRN00123", "12345", "PT-2024-001", "", "TKN_", "TKN_SHORT",
"tkn_" + "A" * 27, "TKN_" + "a" * 27):
assert not is_valid_token(bad)
def test_rejects_non_string():
assert not is_valid_token(None)
assert not is_valid_token(12345)
- Step 2: Run tests to verify they fail
Run: python -m pytest tests/test_token_format.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'core.tokens'
- Step 3: Write the implementation
# python-backend/core/tokens/format.py
"""
Game Center token format.
Canonical token: "TKN_" + 26 Crockford-Base32 chars (130 bits from the OS
CSPRNG) + 1 checksum char. Total length 31.
Tokens are opaque crosswalk keys, NEVER derived from any patient attribute
(compliance-critical: 45 CFR 164.514(c) requires a re-identification code
be "not derived from or related to information about the individual").
The "TKN_" prefix makes accidental leaks greppable; a token without the
clinic-held crosswalk is just a random string.
"""
import secrets
TOKEN_PREFIX = "TKN_"
# Crockford Base32 — excludes I, L, O, U (ambiguous glyphs, accidental words).
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
BODY_LENGTH = 26 # 26 chars x 5 bits = 130 bits of entropy
TOKEN_LENGTH = len(TOKEN_PREFIX) + BODY_LENGTH + 1 # prefix + body + checksum
def _checksum_char(body: str) -> str:
"""Unweighted mod-32 sum: provably catches ANY single-character
substitution (the delta of two distinct alphabet indexes is never 0
mod 32). Transpositions are not caught — acceptable, tokens are
copy-pasted or VLOOKUP'd, rarely hand-keyed."""
total = sum(ALPHABET.index(c) for c in body)
return ALPHABET[total % 32]
def generate() -> str:
"""One canonical token from the OS CSPRNG. Never uses `random`."""
body = "".join(secrets.choice(ALPHABET) for _ in range(BODY_LENGTH))
return f"{TOKEN_PREFIX}{body}{_checksum_char(body)}"
def is_valid_token(value) -> bool:
"""Format + checksum validation only. Registry membership (was this
token actually minted for this org?) is a separate check."""
if not isinstance(value, str) or len(value) != TOKEN_LENGTH:
return False
if not value.startswith(TOKEN_PREFIX):
return False
body, check = value[len(TOKEN_PREFIX):-1], value[-1]
if any(c not in ALPHABET for c in body) or check not in ALPHABET:
return False
return _checksum_char(body) == check
Also create the empty package marker: python-backend/core/tokens/__init__.py (zero bytes is fine).
- Step 4: Run tests to verify they pass
Run: python -m pytest tests/test_token_format.py -v
Expected: 7 passed
- Step 5: Commit
git add python-backend/core/tokens/__init__.py python-backend/core/tokens/format.py tests/test_token_format.py
git commit -m "feat(tokens): Game Center token format - 130-bit CSPRNG, checksum, validator"
Task 2: Blind mint (core/tokens/mint.py)
Files: Create python-backend/core/tokens/mint.py, tests/test_token_mint.py.
- Step 1: Write the failing tests
# tests/test_token_mint.py
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
import pytest
from core.tokens.format import is_valid_token
from core.tokens.mint import MINT_CAP, mint
def test_mint_returns_requested_count_of_valid_tokens():
tokens = mint(50)
assert len(tokens) == 50
assert all(is_valid_token(t) for t in tokens)
def test_mint_batch_is_unique():
assert len(set(mint(500))) == 500
def test_mint_rejects_nonpositive_and_noninteger():
for bad in (0, -1, 2.5, "10", None):
with pytest.raises(ValueError):
mint(bad)
def test_mint_rejects_over_cap():
with pytest.raises(ValueError):
mint(MINT_CAP + 1)
- Step 2: Run tests to verify they fail
Run: python -m pytest tests/test_token_mint.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'core.tokens.mint'
- Step 3: Write the implementation
# python-backend/core/tokens/mint.py
"""
Blind bulk mint. The ONLY input is a count — identity has nowhere to ride.
This is the structural guarantee of the Game Center layer: STTIL mints
tokens without ever learning who they will map to. Never add parameters
that could carry patient attributes.
"""
from .format import generate
MINT_CAP = 50_000 # per-request cap; resource-exhaustion guard
def mint(count) -> list[str]:
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
raise ValueError("count must be a positive integer")
if count > MINT_CAP:
raise ValueError(f"count exceeds mint cap of {MINT_CAP}")
tokens: set[str] = set()
while len(tokens) < count: # collision retry; astronomically rare at 130 bits
tokens.add(generate())
return sorted(tokens)
- Step 4: Run tests to verify they pass
Run: python -m pytest tests/test_token_mint.py -v
Expected: 4 passed
- Step 5: Commit
git add python-backend/core/tokens/mint.py tests/test_token_mint.py
git commit -m "feat(tokens): blind bulk mint - count-only input, 50k cap"
Task 3: Registry (core/tokens/registry.py + migration)
Files: Create python-backend/core/tokens/registry.py, supabase/migrations/20260708000001_token_registry.sql (content in section 3 above), tests/test_token_registry.py.
-
Step 1: Write the migration file — copy the SQL from section 3 verbatim into
supabase/migrations/20260708000001_token_registry.sql. -
Step 2: Write the failing tests (unit-level with a fake client; no live Supabase needed)
# tests/test_token_registry.py
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
from core.tokens import registry
class FakeResponse:
def __init__(self, data):
self.data = data
class FakeTable:
"""Minimal supabase-py table stub recording calls and returning canned data."""
def __init__(self, store, name):
self.store, self.name = store, name
self._filters = {}
def insert(self, rows):
self.store.setdefault(self.name, []).extend(rows if isinstance(rows, list) else [rows])
self._result = FakeResponse(rows if isinstance(rows, list) else [rows])
return self
def select(self, *_):
return self
def eq(self, col, val):
self._filters[col] = val
return self
def in_(self, col, vals):
rows = [r for r in self.store.get(self.name, [])
if r.get(col) in set(vals)
and all(r.get(k) == v for k, v in self._filters.items() if k != "status")]
self._result = FakeResponse(rows)
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)
return self
def execute(self):
return getattr(self, "_result", FakeResponse([]))
class FakeClient:
def __init__(self):
self.store = {}
def table(self, name):
return FakeTable(self.store, name)
def test_record_mint_persists_tokens_and_batch(monkeypatch):
fake = FakeClient()
monkeypatch.setattr(registry, "get_client", lambda: fake)
registry.record_mint("org-1", ["TKN_A", "TKN_B"], minted_by="user_1")
assert len(fake.store["tokens"]) == 2
assert all(r["org_id"] == "org-1" for r in fake.store["tokens"])
assert fake.store["token_mint_batches"][0]["count"] == 2
# COMPLIANCE LOCK: no identity-shaped keys ever persisted
for row in fake.store["tokens"]:
assert set(row.keys()) == {"org_id", "token"}
def test_minted_subset_returns_only_known_org_tokens(monkeypatch):
fake = FakeClient()
monkeypatch.setattr(registry, "get_client", lambda: fake)
registry.record_mint("org-1", ["TKN_A", "TKN_B"])
known = registry.minted_subset("org-1", ["TKN_A", "TKN_X"])
assert known == {"TKN_A"}
def test_revoke_org_purges_and_reports_counts(monkeypatch):
fake = FakeClient()
monkeypatch.setattr(registry, "get_client", lambda: fake)
registry.record_mint("org-1", ["TKN_A", "TKN_B"])
registry.record_mint("org-2", ["TKN_C"])
counts = registry.revoke_org("org-1")
assert counts["tokens"] == 2
assert len(fake.store["tokens"]) == 1 # org-2 untouched
- Step 3: Run tests to verify they fail
Run: python -m pytest tests/test_token_registry.py -v
Expected: FAIL with ModuleNotFoundError or AttributeError on registry
- Step 4: Write the implementation
# python-backend/core/tokens/registry.py
"""
Token registry — Supabase persistence for minted tokens.
STTIL stores ONLY opaque token values, org-scoped. COMPLIANCE LOCK: no
identity columns exist in this schema and none may ever be added. The
insert row shape is exactly {org_id, token}; widening it is a design
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).
ORG_SCOPED_PURGE_TABLES = [
"tokens",
"token_mint_batches",
"normalized_records",
"mapping_decisions",
"report_runs",
"upload_batches",
"source_files",
"export_files",
"confirmed_visits",
"doc_status",
]
def record_mint(org_id: str, tokens: list[str], minted_by: str | None = None) -> None:
client = get_client()
rows = [{"org_id": org_id, "token": t} for t in tokens]
client.table("tokens").insert(rows).execute()
client.table("token_mint_batches").insert(
{"org_id": org_id, "count": len(tokens), "minted_by": minted_by}
).execute()
def minted_subset(org_id: str, tokens: list[str]) -> set[str]:
"""Return the subset of `tokens` that are active minted tokens for this
org. Chunked to keep IN-lists bounded."""
client = get_client()
found: set[str] = set()
for i in range(0, len(tokens), 200):
chunk = tokens[i:i + 200]
resp = (
client.table("tokens")
.select("token")
.eq("org_id", org_id)
.eq("status", "active")
.in_("token", chunk)
.execute()
)
found.update(row["token"] for row in (resp.data or []))
return found
def revoke_org(org_id: str) -> dict[str, int]:
"""Offboarding purge: hard-delete all org-scoped rows. The clinic-side
crosswalk destruction (contractual) is the actual de-identification
event; this purge is defense in depth. Returns per-table row counts
for the audit tombstone (counts only — never token values)."""
client = get_client()
counts: dict[str, int] = {}
for table in ORG_SCOPED_PURGE_TABLES:
try:
resp = client.table(table).delete().eq("org_id", org_id).execute()
counts[table] = len(resp.data or [])
except Exception:
counts[table] = -1 # -1 = purge failed for this table; caller surfaces it
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.
- Step 5: Run tests to verify they pass
Run: python -m pytest tests/test_token_registry.py -v
Expected: 3 passed
-
Step 6: Apply the migration (CLI only, mirroring how
20260607000001_audit_log_worm.sqlwas applied):supabase db pushfrom the repo root, orpsql $SUPABASE_DB_URL -f supabase/migrations/20260708000001_token_registry.sqlif the CLI is not linked. Verify: queryselect count(*) from tokens;returns 0, not an error. -
Step 7: Commit
git add python-backend/core/tokens/registry.py supabase/migrations/20260708000001_token_registry.sql tests/test_token_registry.py
git commit -m "feat(tokens): org-scoped token registry + Supabase migration + offboarding purge"
Task 4: Blind mint endpoint (POST /api/token/mint)
Files: Modify python-backend/api/main.py (new endpoint + models), modify python-backend/core/audit_logger.py (2 enum lines), create tests/test_token_mint_endpoint.py.
- Step 1: Add audit actions — in
python-backend/core/audit_logger.py, extend theAuditActionenum (currently ends atRECORD_VIEW = "record_view", line ~51):
TOKEN_MINT = "token_mint"
ORG_REVOKE = "org_revoke"
- Step 2: Write the failing tests
# tests/test_token_mint_endpoint.py
"""
The mint endpoint is BLIND: its schema accepts exactly one field (count).
The identity-cannot-cross test is the compliance-critical one — any extra
field must be rejected at the schema boundary (422), never silently ignored.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
import pytest
from fastapi.testclient import TestClient
import api.main as main_mod
from api.main import app, require_auth
@pytest.fixture
def client(monkeypatch):
app.dependency_overrides[require_auth] = lambda: {
"sub": "test_user", "o": {"id": "org_clerk_test"},
}
monkeypatch.setattr(main_mod, "get_or_create_org", lambda clerk_org_id=None: "org-uuid-1")
recorded = {}
def fake_record_mint(org_id, tokens, minted_by=None):
recorded["org_id"], recorded["tokens"], recorded["minted_by"] = org_id, tokens, minted_by
import core.tokens.registry as reg
monkeypatch.setattr(reg, "record_mint", fake_record_mint)
monkeypatch.setattr(main_mod, "log_event", lambda **kw: recorded.setdefault("audit", kw))
yield TestClient(app), recorded
app.dependency_overrides.clear()
def test_mint_returns_valid_tokens(client):
c, recorded = client
resp = c.post("/api/token/mint", json={"count": 5})
assert resp.status_code == 200
body = resp.json()
assert body["count"] == 5 and len(body["tokens"]) == 5
assert recorded["org_id"] == "org-uuid-1"
from core.tokens.format import is_valid_token
assert all(is_valid_token(t) for t in body["tokens"])
def test_identity_cannot_cross_extra_fields_rejected(client):
c, _ = client
for smuggle in (
{"count": 5, "patient_name": "John Doe"},
{"count": 5, "mrn": "MRN0042"},
{"count": 5, "note": "for the Smith family"},
):
resp = c.post("/api/token/mint", json=smuggle)
assert resp.status_code == 422, f"extra field accepted: {smuggle}"
def test_mint_cap_enforced(client):
c, _ = client
assert c.post("/api/token/mint", json={"count": 50_001}).status_code == 422
assert c.post("/api/token/mint", json={"count": 0}).status_code == 422
def test_audit_logs_count_never_token_values(client):
c, recorded = client
resp = c.post("/api/token/mint", json={"count": 3})
assert resp.status_code == 200
audit_str = str(recorded.get("audit", {}))
for token in resp.json()["tokens"]:
assert token not in audit_str
If fastapi.testclient raises ImportError: httpx, run pip install httpx (test-only dependency; do not add it to the deploy requirements).
- Step 3: Run tests to verify they fail
Run: python -m pytest tests/test_token_mint_endpoint.py -v
Expected: FAIL with 404 on /api/token/mint (route doesn't exist)
- Step 4: Implement the endpoint — in
python-backend/api/main.py:
(a) Extend the pydantic import at line ~18 from from pydantic import BaseModel to:
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.)
(b) Add near the other request models (e.g., after class ExportRequest, line ~891):
class MintRequest(BaseModel):
"""Blind mint request. COMPLIANCE LOCK: exactly one field, extra
forbidden. Identity cannot cross because there is nowhere to put it.
Never add a string field to this model."""
model_config = ConfigDict(extra="forbid")
count: int = Field(..., ge=1, le=50_000)
class MintResponse(BaseModel):
tokens: list[str]
count: int
(c) Add the endpoint (place after the /api/export endpoint):
@app.post("/api/token/mint", response_model=MintResponse)
async def mint_tokens(
body: MintRequest,
claims: dict = Depends(require_auth),
):
"""Game Center blind mint: returns freshly minted org-scoped tokens.
The clinic maps them to patients in ITS OWN crosswalk; STTIL never
sees, accepts, or stores the mapping."""
from core.tokens.mint import mint
from core.tokens import registry as token_registry
clerk_org_id = (
claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None
)
org_id = get_or_create_org(clerk_org_id=clerk_org_id)
if not org_id:
raise HTTPException(
status_code=400, detail="No organization resolved from auth claims."
)
tokens = mint(body.count)
token_registry.record_mint(org_id, tokens, minted_by=claims.get("sub", "demo_user"))
from core.supabase_client import get_client
log_event(
action=AuditAction.TOKEN_MINT,
resource_id=f"token-mint:{body.count}",
user_id=claims.get("sub", "demo_user"),
outcome="success",
ip_address="0.0.0.0",
detail=f"minted {len(tokens)} tokens", # counts only, NEVER token values
db_conn=get_client(),
)
return MintResponse(tokens=tokens, count=len(tokens))
- Step 5: Run tests to verify they pass
Run: python -m pytest tests/test_token_mint_endpoint.py -v
Expected: 4 passed
-
Step 6: Run the FULL suite —
python -m pytest tests/ -v— expected: no regressions vs the precondition count. -
Step 7: Commit
git add python-backend/api/main.py python-backend/core/audit_logger.py tests/test_token_mint_endpoint.py
git commit -m "feat(tokens): blind mint endpoint - schema-locked count-only request"
Task 5: Normalizer token mode
Files: Modify python-backend/api/normalizer.py, create tests/test_normalizer_token_mode.py.
Behavior: IDENTITY_MODE=patient_id (default) → byte-identical to today. IDENTITY_MODE=token → the identity value must be a valid TKN_ token (arriving via a token-named column or the patient_id column); any non-token identifier is skipped with a reason that NEVER echoes the raw value (it could be an MRN — echoing it would put identity in responses and logs).
- Step 1: Write the failing tests
# tests/test_normalizer_token_mode.py
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
from api.normalizer import normalize_csv
from core.tokens.format import generate
CSV_LEGACY = (
"patient_id,device_type,shipment_date,quantity,payer\n"
"PT-001,Dexcom G7,2026-06-01,3,Medicare Part B\n"
"PT-002,Libre 3,2026-06-15,1,Aetna\n"
)
def test_default_mode_is_byte_identical_regression_lock(monkeypatch):
monkeypatch.delenv("IDENTITY_MODE", raising=False)
records, skipped, summary = normalize_csv(CSV_LEGACY)
assert [r.patient_id for r in records] == ["PT-001", "PT-002"]
assert skipped == []
assert summary["required_missing"] == []
def test_token_mode_accepts_token_column(monkeypatch):
monkeypatch.setenv("IDENTITY_MODE", "token")
t1, t2 = generate(), generate()
csv_text = (
"token,device_type,shipment_date,quantity,payer\n"
f"{t1},Dexcom G7,2026-06-01,3,Medicare Part B\n"
f"{t2},Libre 3,2026-06-15,1,Aetna\n"
)
records, skipped, _ = normalize_csv(csv_text)
assert [r.patient_id for r in records] == [t1, t2]
assert skipped == []
def test_token_mode_accepts_token_in_patient_id_column(monkeypatch):
monkeypatch.setenv("IDENTITY_MODE", "token")
t1 = generate()
csv_text = (
"patient_id,device_type,shipment_date\n"
f"{t1},Dexcom G7,2026-06-01\n"
)
records, skipped, _ = normalize_csv(csv_text)
assert records[0].patient_id == t1
def test_token_mode_rejects_raw_mrn_without_echoing_it(monkeypatch):
monkeypatch.setenv("IDENTITY_MODE", "token")
csv_text = (
"patient_id,device_type,shipment_date\n"
"MRN-SECRET-0042,Dexcom G7,2026-06-01\n"
)
records, skipped, _ = normalize_csv(csv_text)
assert records == []
assert len(skipped) == 1
assert "MRN-SECRET-0042" not in skipped[0] # NEVER echo a rejected identifier
assert "token" in skipped[0].lower()
def test_token_mode_flags_missing_identity_column(monkeypatch):
monkeypatch.setenv("IDENTITY_MODE", "token")
csv_text = "device_type,shipment_date\nDexcom G7,2026-06-01\n"
_, _, summary = normalize_csv(csv_text)
assert "sttil_token" in summary["required_missing"]
- Step 2: Run tests to verify they fail
Run: python -m pytest tests/test_normalizer_token_mode.py -v
Expected: the default-mode test passes; the four token-mode tests FAIL (no token handling exists)
- Step 3: Implement — in
python-backend/api/normalizer.py:
(a) Add import os to the imports (line ~8 block) and, after the from core.coverage_calculator import ShipmentRecord import:
from core.tokens.format import is_valid_token
def _identity_mode() -> str:
""""patient_id" (default, legacy) or "token" (Game Center). Read per
call so tests and deployments flip it via env without reimport."""
mode = os.getenv("IDENTITY_MODE", "patient_id").strip().lower()
return mode if mode in ("patient_id", "token") else "patient_id"
(b) Add a token entry to HEADER_MAP (after the "patient_id" entry):
"sttil_token": [
"sttil_token", "sttil token", "token", "signal_token", "signal token",
"patient_token", "patient token", "game_center_token", "gc_token",
],
(c) In normalize_csv, replace the required_missing line (currently required_missing = [f for f in required_fields if f not in mapping_detail]) with:
required_missing = [f for f in required_fields if f not in mapping_detail]
identity_mode = _identity_mode()
if identity_mode == "token":
required_missing = [
f for f in ("device_type", "shipment_date") if f not in mapping_detail
]
if "sttil_token" not in mapping_detail and "patient_id" not in mapping_detail:
required_missing.append("sttil_token")
(d) In the row loop, replace the identity block (currently the three lines starting patient_id = mapped.get("patient_id", "").strip() through continue) with:
if identity_mode == "token":
candidate = (
mapped.get("sttil_token", "").strip()
or mapped.get("patient_id", "").strip()
)
if not candidate:
skipped.append(f"Row {i}: missing token")
continue
if not is_valid_token(candidate):
# NEVER echo the rejected value — it could be a raw MRN.
skipped.append(
f"Row {i}: identifier is not a valid STTIL token "
"(token mode accepts only TKN_-format tokens; raw IDs are rejected)"
)
continue
patient_id = candidate
else:
patient_id = mapped.get("patient_id", "").strip()
if not patient_id:
skipped.append(f"Row {i}: missing patient_id")
continue
- Step 4: Run tests to verify they pass
Run: python -m pytest tests/test_normalizer_token_mode.py -v
Expected: 5 passed
-
Step 5: Regression check the full suite —
python -m pytest tests/ -v— the pre-existing normalizer/e2e tests must all still pass (they run in default mode). -
Step 6: Commit
git add python-backend/api/normalizer.py tests/test_normalizer_token_mode.py
git commit -m "feat(tokens): IDENTITY_MODE token path in normalizer - default byte-identical"
Task 6: Upload registry-enforcement guard
Files: Modify python-backend/api/main.py (upload_csv), create tests/test_upload_token_guard.py.
Behavior: with IDENTITY_MODE=token AND TOKEN_ENFORCE_REGISTRY=true, an upload containing tokens not minted for the caller's org is rejected 422 (sole-minter rule, K6). Registry unreachable in enforce mode → 503 fail-CLOSED. Both flags off → zero change.
- Step 1: Write the failing tests
# tests/test_upload_token_guard.py
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
import io
import pytest
from fastapi.testclient import TestClient
import api.main as main_mod
from api.main import app, require_auth
from core.tokens.format import generate
@pytest.fixture
def token_mode_client(monkeypatch):
monkeypatch.setenv("IDENTITY_MODE", "token")
monkeypatch.setenv("TOKEN_ENFORCE_REGISTRY", "true")
app.dependency_overrides[require_auth] = lambda: {
"sub": "test_user", "o": {"id": "org_clerk_test"},
}
monkeypatch.setattr(main_mod, "get_or_create_org", lambda clerk_org_id=None: "org-uuid-1")
yield TestClient(app), monkeypatch
app.dependency_overrides.clear()
def _csv_upload(client, tokens):
rows = "\n".join(f"{t},Dexcom G7,2026-06-01,3,Medicare Part B" for t in tokens)
text = f"token,device_type,shipment_date,quantity,payer\n{rows}\n"
return client.post(
"/api/upload",
files={"file": ("orders.csv", io.BytesIO(text.encode()), "text/csv")},
)
def test_unminted_tokens_rejected(token_mode_client):
client, monkeypatch = token_mode_client
import core.tokens.registry as reg
minted = generate()
stranger = generate()
monkeypatch.setattr(reg, "minted_subset", lambda org_id, toks: {minted})
resp = _csv_upload(client, [minted, stranger])
assert resp.status_code == 422
assert "not minted" in str(resp.json())
def test_registry_failure_fails_closed(token_mode_client):
client, monkeypatch = token_mode_client
import core.tokens.registry as reg
def boom(org_id, toks):
raise RuntimeError("supabase down")
monkeypatch.setattr(reg, "minted_subset", boom)
resp = _csv_upload(client, [generate()])
assert resp.status_code == 503
Note: the all-minted happy path continues past the guard into Supabase-backed persistence, so it is covered by the pipeline test in Task 10 rather than here.
- Step 2: Run tests to verify they fail
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, insideupload_csv, immediately AFTER theorg_id = get_or_create_org(clerk_org_id=clerk_org_id)line and BEFOREconfirmed_visits = ...:
# Game Center guard (K6, sole minter): in token mode with enforcement on,
# every uploaded token must have been minted for THIS org.
from api.normalizer import _identity_mode
if (
_identity_mode() == "token"
and os.getenv("TOKEN_ENFORCE_REGISTRY", "false").strip().lower() == "true"
):
if not org_id:
raise HTTPException(
status_code=400,
detail="Token enforcement requires an organization context.",
)
from core.tokens import registry as token_registry
upload_tokens = list({r.patient_id for r in records})
try:
known = token_registry.minted_subset(org_id, upload_tokens)
except Exception as e:
# Fail CLOSED: in enforce mode an unreachable registry rejects
# the upload rather than letting unverified tokens through.
raise HTTPException(
status_code=503,
detail="Token registry unavailable; upload rejected in enforce mode.",
) from e
unknown = sorted(set(upload_tokens) - known)
if unknown:
raise HTTPException(
status_code=422,
detail={
"message": (
f"{len(unknown)} token(s) in this file were not minted "
"for this organization."
),
"unknown_tokens_sample": unknown[:5],
},
)
(api.normalizer is imported lazily inside the function to match the file's existing local-import style; os is already imported at module top.)
- Step 4: Run tests to verify they pass
Run: python -m pytest tests/test_upload_token_guard.py -v
Expected: 2 passed
- Step 5: Commit
git add python-backend/api/main.py tests/test_upload_token_guard.py
git commit -m "feat(tokens): registry-enforcement upload guard - fail-closed, sole-minter"
Task 7: Audit-log identifier hardening
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).
- Step 1: Find both spots
Run: grep -n "patient_id\[:8\]" python-backend/api/main.py
Expected: exactly 2 hits (confirm-visit: ~line 1101, update-doc-status: ~line 1193). If the readiness lane moved them, adjust line numbers, not the approach.
- Step 2: Replace both. Change
resource_id=f"confirm-visit:{body.patient_id[:8]}",
to
resource_id=f"confirm-visit:{_sha256(body.patient_id)[:12]}",
and the same pattern for update-doc-status:. Add the import next to the other core.persistence imports at the top of main.py:
from core.persistence import _sha256
(If main.py imports persistence functions lazily, follow that style instead: from core.persistence import _sha256 inside each handler.)
- Step 3: Verify no raw-identifier patterns remain
Run: grep -n "patient_id\[" python-backend/api/main.py
Expected: no matches embedding raw slices into log/audit strings.
-
Step 4: Full suite —
python -m pytest tests/ -v— expected: no regressions (if an existing test asserts the oldresource_idformat, update that assertion to the hashed form — the raw form violated the repo's own logging rule). -
Step 5: Commit
git add python-backend/api/main.py
git commit -m "fix(audit): hash identifier prefixes in audit resource_ids per PHI logging rule"
Task 8: Offboarding endpoint (POST /api/org/revoke)
Files: Modify python-backend/api/main.py. Registry logic already exists (Task 3); this exposes it with an explicit confirmation phrase and an audit tombstone.
- Step 1: Add the endpoint (after the mint endpoint):
class RevokeOrgRequest(BaseModel):
"""Offboarding is destructive. The caller must type the org's Clerk ID
as confirmation; no other body fields exist."""
model_config = ConfigDict(extra="forbid")
confirm_clerk_org_id: str
@app.post("/api/org/revoke")
async def revoke_org_endpoint(
body: RevokeOrgRequest,
claims: dict = Depends(require_auth),
):
"""Game Center offboarding: purge all org-scoped rows (tokens + operational
data) and write an audit tombstone with row counts only. The clinic-side
crosswalk destruction (contractual) is the actual de-identification event;
this purge is defense in depth."""
clerk_org_id = (
claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None
)
if not clerk_org_id or body.confirm_clerk_org_id != clerk_org_id:
raise HTTPException(
status_code=400,
detail="Confirmation mismatch: confirm_clerk_org_id must equal the caller's org.",
)
org_id = get_or_create_org(clerk_org_id=clerk_org_id)
if not org_id:
raise HTTPException(status_code=400, detail="No organization resolved.")
from core.tokens import registry as token_registry
counts = token_registry.revoke_org(org_id)
from core.supabase_client import get_client
log_event(
action=AuditAction.ORG_REVOKE,
resource_id=f"org-revoke:{org_id}",
user_id=claims.get("sub", "demo_user"),
outcome="failure" if any(v == -1 for v in counts.values()) else "success",
ip_address="0.0.0.0",
detail=f"purged row counts: {counts}", # counts only, never token values
db_conn=get_client(),
)
if any(v == -1 for v in counts.values()):
raise HTTPException(
status_code=500,
detail={"message": "Purge incomplete; see counts.", "counts": counts},
)
return {"revoked_org": org_id, "purged": counts}
- Step 2: Add tests — append to
tests/test_token_mint_endpoint.py(same fixture pattern; monkeypatchreg.revoke_orgto return{"tokens": 2}for success and{"tokens": -1}for the failure case):
def test_revoke_requires_matching_confirmation(client):
c, _ = client
resp = c.post("/api/org/revoke", json={"confirm_clerk_org_id": "wrong_org"})
assert resp.status_code == 400
def test_revoke_purges_and_reports_counts(client, monkeypatch):
c, _ = client
import core.tokens.registry as reg
monkeypatch.setattr(reg, "revoke_org", lambda org_id: {"tokens": 2, "doc_status": 1})
resp = c.post("/api/org/revoke", json={"confirm_clerk_org_id": "org_clerk_test"})
assert resp.status_code == 200
assert resp.json()["purged"]["tokens"] == 2
def test_revoke_surfaces_partial_failure(client, monkeypatch):
c, _ = client
import core.tokens.registry as reg
monkeypatch.setattr(reg, "revoke_org", lambda org_id: {"tokens": -1})
resp = c.post("/api/org/revoke", json={"confirm_clerk_org_id": "org_clerk_test"})
assert resp.status_code == 500
-
Step 3: Run
python -m pytest tests/test_token_mint_endpoint.py -v— expected: all pass (7 total in the file now). -
Step 4: Commit
git add python-backend/api/main.py tests/test_token_mint_endpoint.py
git commit -m "feat(tokens): org offboarding endpoint - confirmed purge + audit tombstone"
Task 9: Clinic onboarding artifacts
Files: Create docs/onboarding/crosswalk-template.csv, docs/onboarding/token-crosswalk-runbook.md. Documentation only — review gate, no tests.
- Step 1: Create
docs/onboarding/crosswalk-template.csv
token,your_patient_id,your_patient_name,notes
TKN_EXAMPLE0000000000000000000,,,"Replace this row. Paste STTIL-minted tokens in column 1; fill columns 2-4 from YOUR system. THIS FILE NEVER LEAVES YOUR ORGANIZATION."
- Step 2: Create
docs/onboarding/token-crosswalk-runbook.md
# Signal Token Onboarding Runbook (Game Center)
For clinic/supplier staff. STTIL never sees your patient identities. This
runbook is how that stays true.
## What you hold vs what STTIL holds
- YOU hold: the crosswalk file (token -> your patient). Store it wherever
you store PHI today. It never leaves your organization.
- STTIL holds: the tokens and documentation-status data only. No names,
no DOBs, no MRNs, no contact info.
## Onboarding (one-time, ~30 minutes)
1. Your Signal admin signs in and requests tokens: your account team runs
the mint for your expected patient count plus headroom (e.g. 500
patients -> mint 750). You receive a one-column CSV of tokens.
2. Open the crosswalk template (crosswalk-template.csv). Paste the minted
tokens into column 1. Fill your patient identifiers in columns 2-4 from
your own system, one row per patient. Save it in your PHI-grade storage.
Delete the example row.
3. THIS FILE IS THE CROSSWALK. Never email it to STTIL, never attach it to
a support ticket, never upload it to Signal. If STTIL ever receives it,
report it to your compliance officer and to STTIL immediately.
## Every upload
1. Export your order data as usual (Brightree or equivalent).
2. Replace the patient ID column with the token column: VLOOKUP each
patient against your crosswalk file (token for patient_id).
3. Name the column "token". Upload the CSV to Signal as usual.
4. Signal's worklist shows tokens. To act on a flagged patient, look the
token up in YOUR crosswalk. STTIL cannot do this lookup for you - by
design, it does not have the file.
## Rejected rows
If Signal reports "identifier is not a valid STTIL token", a raw patient
ID slipped through your VLOOKUP. Fix the mapping and re-upload. Signal
rejects these rows without storing or echoing them.
## Adding patients
Request another mint batch any time. Add the new tokens to your crosswalk.
## Offboarding
1. STTIL purges all your organization's data (tokens + documentation
status) and provides the purge receipt (row counts).
2. You destroy your crosswalk file. Once destroyed, nothing anywhere can
link a token to a patient - that destruction is the point.
- Step 3: Commit
git add docs/onboarding/crosswalk-template.csv docs/onboarding/token-crosswalk-runbook.md
git commit -m "docs(tokens): clinic crosswalk template + onboarding runbook"
Task 10: Pipeline end-to-end test (token mode)
Files: Create tests/test_token_pipeline_e2e.py. Hermetic (no Supabase): drives mint → CSV → normalize → dedup → coverage exactly as upload_csv does.
- Step 1: Write the test
# tests/test_token_pipeline_e2e.py
"""
End-to-end (in-process): minted tokens flow through the normalizer and the
scoring pipeline exactly like legacy patient_ids, because every engine
treats the identifier as an opaque string. This is the structural claim of
the Game Center seam - prove it.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
from datetime import date
from api.normalizer import normalize_csv
from core.coverage_calculator import calculate_batch
from core.tokens.mint import mint
def test_tokens_flow_through_scoring_pipeline(monkeypatch):
monkeypatch.setenv("IDENTITY_MODE", "token")
tokens = mint(3)
rows = "\n".join(
f"{t},Dexcom G7,2026-06-0{i + 1},3,Medicare Part B"
for i, t in enumerate(tokens)
)
csv_text = f"token,device_type,shipment_date,quantity,payer\n{rows}\n"
records, skipped, summary = normalize_csv(csv_text)
assert skipped == []
assert summary["required_missing"] == []
assert sorted(r.patient_id for r in records) == sorted(tokens)
results = calculate_batch(records, as_of=date(2026, 7, 1), confirmed_visits={})
assert len(results) == len(tokens)
result_ids = {getattr(r, "patient_id", None) or r.get("patient_id") for r in results} \
if results and isinstance(results[0], dict) else {r.patient_id for r in results}
assert result_ids == set(tokens)
def test_mixed_file_keeps_tokens_drops_raw_ids(monkeypatch):
monkeypatch.setenv("IDENTITY_MODE", "token")
good = mint(1)[0]
csv_text = (
"token,device_type,shipment_date\n"
f"{good},Dexcom G7,2026-06-01\n"
"MRN-LEAKED-99,Dexcom G7,2026-06-02\n"
)
records, skipped, _ = normalize_csv(csv_text)
assert [r.patient_id for r in records] == [good]
assert len(skipped) == 1 and "MRN-LEAKED-99" not in skipped[0]
Before running, check calculate_batch's return shape (grep -n "def calculate_batch" -A 10 python-backend/core/coverage_calculator.py) and keep whichever branch of the result_ids extraction matches; delete the other.
-
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 4: Commit
git add tests/test_token_pipeline_e2e.py
git commit -m "test(tokens): pipeline e2e - tokens traverse scoring unchanged, raw IDs dropped"
Task 11: Deploy dark + live verification
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 redeployonly reuses the image). Then pollrailway status --jsonuntil the active deployment showsstatus: SUCCESSand the instance isRUNNING. - Step 2: Verify legacy behavior unchanged —
curl -s https://signal-api-production-91c2.up.railway.app/healthreturns healthy; run the existing smoke flow (tests/smoke_test.pyif 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=tokenon 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 5: Commit anything outstanding, then update the ship ledger — add a line to
docs/ship-status-ledger.mddescribing what shipped and that token mode is dark.
Task 12: State sync + handoff hygiene (ALWAYS run, even if stopping early)
- Step 1: Update
context/current-state.mdvia the state-sync skill: note Game Center progress in ACTIVE/NEXT, append ONE author-stamped decision line (e.g.- 2026-07-XX [<you>]: Game Center token layer Tasks 1-N built per docs/build-plans/03-game-center-token-layer.md; token mode dark; K1-K6 pending Kisa.), commit + push. - Step 2: If Kisa confirmed any K-decision in-session, run the Decision Protocol (kg_query for conflicts, then kg_add). Otherwise add nothing to the KG.
- Step 3: Reconcile the RAP-ID note (only if K1 confirmed): edit
context/current-state.mdStack line "tokenization via RAP-ID/CSP-ID (rotating anonymous ID, not static UUID)" to "tokenization via Game Center stable per-clinic opaque tokens (rotation re-homed to optional v2 display aliases)". - Step 4: Update
signal/CLAUDE.md"Gate: Token Layer" table rows to reflect what is now built (token generation service; crosswalk handoff protocol; Signal accepts tokens) — only mark an item DONE if its tests are green and committed. - Step 5: Attorney brief — confirm the questions in section 8 are queued for the attorney email bundle (see reactivation plan
docs/build-plans/00-signal-reactivation-plan.md, attorney thread).
7. Design rationale (for reviewers; the tasks above already encode these)
- Blind mint over per-patient tokenize-API: a "tokenize this MRN" endpoint requires identity to cross the wire even if discarded — STTIL would have touched the map, destroying the "provably never held it" thesis. The count-only schema makes crossing structurally impossible. GROUNDED in the concept doc; schema-lock mechanism GRAFTED (design choice).
- One token per (clinic, patient), not global: a global token requires STTIL to know the same person exists at two clinics — which requires identity. Per-clinic minting defeats cross-clinic correlation as a free structural property. GROUNDED (strengthens concept-doc Q2 from "safer" to "forced").
- Stable tokens, not rotating (K1): Signal's value is longitudinal —
confirmed_visitsanddoc_statuspersist across uploads keyed bysha256(patient_id); rotation orphans all of it and breaks dedup. Rotation adds no protection here because the threats it addresses (linkable leaked identifier) are already neutralized by no-map-at-STTIL + per-clinic scoping. GROUNDED againstpersistence.py. - Opaque format, not memorable names (K2): wordlists collapse entropy, invite informal mnemonics that become identifying, and feel safe enough to paste into tickets. Friendly names belong in the clinic's own crosswalk file, which STTIL never sees. GROUNDED on entropy math; overturns the concept doc's "BlueDragon_4821" illustration.
- Supabase, not a token microservice (K6-adjacent): the token store holds nothing sensitive (opaque strings, no map), Supabase Pro is already paid, and a clean
core/tokens/boundary keeps later extraction reversible. Standalone service is v3+ (licensed-product horizon), YAGNI now. GROUNDED in signal/CLAUDE.md stack + parked-AWS ruling. - Fail-closed enforcement guard: in enforce mode, a down registry rejects uploads. Honest failure over silent acceptance of unverifiable tokens.
- Threat model residuals: tokens in the export CSV are intended (the clinic needs them for lookup); small-cell/aggregate risk is deferred until any benchmarking product exists (flag attorney question 5 then); mint-endpoint abuse bounded by cap + auth. The clinic crosswalk becomes the crown jewels — protection moves to contract + runbook, which is exactly where the design wants it.
8. Attorney brief questions (full text)
- Is STTIL a HIPAA Business Associate for Signal's current and token-mode operation ("acting on behalf of" a covered entity, even receiving only tokenized data)? Datavant precedent: accepts BA status.
- Does 164.514(c) tolerate the vendor (STTIL) generating the code where the covered entity builds and solely holds the crosswalk — or must the covered entity itself "assign" the code?
- 164.514(c) operates on data already de-identified under 164.514(b). What process, run by whom, de-identifies the operational payload (device, dates, quantity, payer) BEFORE the token attaches, and who certifies it?
- Full shipment dates break safe harbor ("all elements of dates except year"). Signal needs granular dates. Is expert determination (164.514(b)(1)) viable, what expert engagement is required, and must it precede first production token-mode use?
- If any cross-org aggregate/benchmark output is ever built: small-cell suppression standard (HHS ≥11 convention)?
- Current Signal exposure: does the "supplier confirms patient_id is not PHI-derivable" contract clause suffice when the supplier's IDs may be sequential/MRN-derived? Must STTIL verify randomness? Can the clause be retired once token mode is live, or must both coexist?
- If STTIL is determined NOT HIPAA-covered: does the FTC Health Breach Notification Rule (2024 amendments; breach includes unauthorized disclosure) reach Signal as a PHR-related entity?
- Offboarding contract language: crosswalk-destruction obligation as the de-identification event + protection standard for the crosswalk while active.
- AWS BAA: confirm executed-and-active vs signed-and-parked, and required activation scope if PHI status is ambiguous.
- Approved external phrasing for the privacy architecture (see NEVER-CLAIM / SAFE-TO-SAY lists in section 1).
Primary sources consulted (2026-07-07): 45 CFR 164.514 (Cornell LII), HHS de-identification guidance, Datavant BAA + de-identification pages, FTC HBNR 2024 final rule + Operation AI Comply releases, HHS/FTC coordination coverage. URLs in the session's compliance research output; re-verify before the attorney meeting.