{
"file_path": "/Users/warrenthompson/Source/LoginID/PLAN.md"
}1 # Plan: Org-wide planning phase for the LoginID take-home ("hire" the sub-teams, plan every track, then Jira)
2
3 ## Context
4
5 The LoginID take-home is being run as a demonstration of AI-architecture capability: a root PM agent (Dana Whitfield / team-lead) plus five persistent track leads (Naomi Voss 01, Marcus Ilori 02, Renata Cole 03, Theo Bergman 04, Priya Nandakumar 05), each with a profile-gen persona, each working from its own track directory. All five tracks have finished their first-wave design docs; all code has been deliberately wiped; the project is paused in a design/planning phase. Marcus has delivered the team's agreed Claude-architecture approach in `02-ai-security-architecture/planning-approach.md` (model/effort per role, orchestrator-worker with bounded two-party exceptions, the "receiver can act without re-deriving" hand-off standard, dependency order 01/02/05 → 03 → 04, and a thin root `PLANNING.md` board).
6
7 Warren now wants the formal planning phase run across the whole org, with three additions to Marcus's approach:
8
9 1. Every track lead must "hire" (create) a sub-team of agents and state each hire's function. Hires must vary in **personality and archetype**, not just viewpoint — variance across the org, not just within a team (a serious principles-based architect paired with a silly innovative one, etc.).
10 2. **Every** agent created must run profile-gen so it has a personality, professional history, and technical background. Profile pictures are optional and may be produced by background jobs.
11 3. The orchestration patterns must make heavy use of those personas to drive debate and diversity of thought.
12
13 Leads may spawn their sub-teams into new tmux panes. Everyone goes into plan mode and plans first; Jira stories in project `LT` (uplifttech.atlassian.net, cloudId `db488eae-d6cb-4100-92a9-e7a4519fc176`, issue types Epic/Story/Task/Bug/Subtask) come **after** planning. No implementation code until Warren gives a separate go-ahead.
14
15 Everything in `/Users/warrenthompson/Source/LoginID` is part of the submission, so the org scaffolding (casting registry, personas, agent definitions) must read as intentional, presentable AI-architecture work — not scratch.
16
17 ## Inputs this plan builds on (already decided — do not re-litigate)
18
19 - `02-ai-security-architecture/planning-approach.md` — models/effort by role (orchestrator fable/high; leads sonnet, medium for 01/04, high for 02/03, **opus/high for 05 planning**; research subagents haiku or sonnet/low; implementation sonnet/medium-high, opus/high only for the connector token lifecycle; review = author self-review first, independent second pass on security-graded surfaces only; fable for a single final cross-track consistency pass), coordination rules, hand-off standard, dependency order, and the thin `PLANNING.md` board shape.
20 - Root `CLAUDE.md` — track ownership table and the "cross-reference, don't restate" rule.
21 - profile-gen mechanics verified in this session (see "Hire creation mechanics" below).
22
23 ## Verified mechanics that shape the design
24
25 profile-gen (`~/.claude/skills/profile-gen` → symlink to `~/Source/ProfileGenSkill`):
26 - `--output file --assets tracked --root <track-dir>` writes `<track-dir>/profiles/<slug>/<slug>.md` with **no** CLAUDE.md line; unlimited personas per directory (`scripts/profilegen/storage.py:98-110`, `write_profile.py:121-142`).
27 - `show_profile.py --autostart-only` displays **every** persona that has a `profile-gen:start` marker in the root `CLAUDE.md`, in file order (`show_profile.py:219-224`). Therefore hires must **never** be added as CLAUDE.md markers — only the five leads and Dana keep markers. Hires are displayed on demand with `show_profile.py --profile <path> --root <track-dir>`.
28 - Schema has no history/backstory field (`assets/profile.schema.json`, `additionalProperties: false`); `personality` is unlimited free prose, so history + technical background live there.
29 - `write_profile.py` only checks the `image` **key** exists, never the file (`write_profile.py:63,90-92`) → personas can be created **text-first** with a pre-declared image path, and the PNG generated later into that path by a background queue. Display degrades gracefully to name-only until the file exists.
30 - `generate_image.py --workflow <any path> --seed N` — the existing validated workflow `profiles/dana-whitfield/comfyui-workflow.json` is reusable for every portrait; it is never mutated.
31 - Global `SessionStart` hook (`~/.claude/settings.json`) runs `show_profile.py --root "${CLAUDE_PROJECT_DIR:-$PWD}" --autostart-only` before a subagent's first turn, so every new pane shows Dana until the agent runs `show_profile.py --profile <own>`; every spawn prompt must include that command after its `cd`.
32
33 Harness (confirmed against the Claude Code docs — sub-agents.md, agent-teams.md, settings-reference.md, hooks.md):
34 - Agent definitions `.claude/agents/<slug>.md` support frontmatter `name`, `description`, `model` (`sonnet|opus|haiku|fable|inherit`), `tools`/`disallowedTools`, `permissionMode`, `skills`, `memory`, `maxTurns`, and a `hooks` block scoped to that agent's lifetime. **No effort key exists**, and teammates inherit the lead's effort level (Warren's `high`). The only per-model lever is `modelSettings.<model>.effortLevel` in `.claude/settings.json`. Consequence: Marcus's effort tiers are implemented as *inherit high for sonnet/opus/fable* plus `haiku → low` for throwaway research fan-out; the sonnet-medium vs sonnet-high distinction collapses to high (small cost, errs upward on the tracks that matter). Model per hire is still pinned in its definition.
35 - The per-agent `hooks` block is the permanent fix for the "every pane shows Dana" problem — with one correction found during Phase 0: agent frontmatter supports only `PreToolUse`, `PostToolUse`, and `Stop` (no `SessionStart`), and `$CLAUDE_PROJECT_DIR` is not available in subagent hooks. So each hire's definition carries a `PreToolUse` hook with `matcher: ".*"` and `once: true` that runs `show_profile.py --clear` then `show_profile.py --profile <own persona> --root <track-dir>` (absolute paths) on the hire's first tool call, then removes itself. The spawn prompt keeps the same command as a fallback.
36 - Nested spawning works but is capped at 3 levels by default (`CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH`). Hierarchy therefore stays **team-lead → lead → hire**; hires do not spawn subagents — any research fan-out is done by the lead. The harness opens a tmux pane per spawned agent.
37 - Teammate plan mode **auto-approves**: a teammate's `ExitPlanMode` is approved in the lead's session without review (agent-teams.md). So plan mode is used for what it is good at — forcing a written plan before any edits — and the real gate is manual: the lead copies its approved plan into `<track-dir>/PLAN.md`, messages team-lead, and does **not** hire until team-lead replies with an explicit "hiring go" after reviewing the roster against the variance rules.
38
39 ## Plan
40
41 ### Phase 0 — Team-lead lays the planning scaffolding (before any lead plans)
42
43 All files are prose; none is code. Root-level files are owned by team-lead only.
44
45 **0. Make the process itself part of the submission.** Warren wants every step and all progress saved so it can be turned in; today the org plan lives only at `~/.claude/plans/cuddly-nibbling-cherny.md`, outside the repo, and the leads' plan-mode files would land there too. Three fixes, all in-repo:
46 - **`PLAN.md`** (root, new) — this plan, copied verbatim into the repo and kept as the living org plan (updated at phase boundaries, not mid-task). Leads' plans go to `<track-dir>/PLAN.md` (already required in Phase 1). `~/.claude/plans/` is treated as scratch from here on.
47 - **`LOG.md`** (root, new) — a dated, append-only progress journal owned by team-lead: one entry per milestone or decision (what happened, who, which files changed, why), backfilled from this session's history so far — scaffolding of the five tracks, the six research passes and their key findings (incl. the Dick Hardt/aauth.dev correction and LoginID's agentic-commerce repositioning), persona generation, the code wipe and return to planning, Marcus's planning-approach synthesis, and this plan. Leads append one line to their own `PLANNING.md` row instead; cross-track events go in `LOG.md` via team-lead. Each debate run's artifact (Phase 3) is itself part of the record.
48 - **`git init`** at the root with a milestone commit at the end of every phase (Phase 0 scaffolding, each approved plan, each completed hiring wave, each debate artifact set, the consistency pass, Jira export). The commit history is the most credible "how the AI workflow actually ran" artifact the submission can carry; commit messages follow the repo attribution convention (Co-Authored-By Claude Fable 5.1 + session link), which itself documents the AI involvement LoginID asked about. Commits only at milestones, never mid-task, and never `.claude/settings.local.json`, image caches, or secrets. A `.gitignore` covers `.claude/settings.local.json` and `*.lock`.
49
50 1. **`PLANNING.md`** (root, new) — exactly the thin board Marcus specified: one row per track — `Track | Lead | Status | Blocking on | Blocked by | Planning notes (relative path) | Hires (count/registered) | Last updated` — plus a **Service boundaries** section that 03 owns and 04 reads (initially "TBD — set by 03 during planning"). One writer per row (the lead); team-lead writes the header and the two Dana rows (orchestrator, cross-track consistency pass). No reasoning in this file — pointers only.
51
52 2. **`CASTING.md`** (root, new) — the org-wide casting registry, hiring rules, and orchestration-pattern catalog. Team-lead owns the rules and palette; each lead owns its own registry rows. Sections:
53
54 - *Archetype palette* (13 — enough slack for a no-repeat-within-team rule at ~11 hires):
55
56 | Archetype | Temperament sketch | Blind spot | Pairs well with |
57 |---|---|---|---|
58 | Principled Architect | standards-first, cites RFC section numbers, reasons threat-model-down | over-specifies; mistakes thoroughness for done | security controls, contracts |
59 | Tinkerer (silly & innovative) | playful, analogy-driven, sketches three odd alternatives before defending one | novelty bias; underweights boring proven controls | any decision that needs alternatives |
60 | Designated Skeptic | argues against the room by default, politely | objections without a counter-proposal | authz scoping, abstraction choices |
61 | Consensus Weaver | finds the shared premise, writes the synthesis | papers over real disagreement | decision records, hand-off prose |
62 | Spreadsheet | wants a number or a table; distrusts adjectives | measures only what is measurable | sizing, TTLs, retention windows |
63 | Storyteller | thinks in user journeys and narrative arcs | charming beats correct | product framing, README, use cases |
64 | Veteran | "I watched this fail in prod in 2019" | fights the last war; pattern-matches too fast | token lifecycle, migrations, ops |
65 | Newcomer | bright, asks why, assumes no jargon | doesn't know what's already settled | legibility tests on every hand-off |
66 | Detail Hawk | nullable-vs-required, off-by-one, error semantics | cannot see the forest | schema, DDL, code review |
67 | Systems Cartographer | draws boundaries and flows; sees interactions | abstracts past the concrete | multi-DB abstraction, service topology |
68 | Adversary | thinks like an attacker, enjoys it | sees threats everywhere, no prioritization | connector, search API |
69 | Minimalist | "what if we didn't build this?" | deletes something load-bearing | infra, scope control |
70 | Enthusiast | bold, optimistic, wants the promising thing tried | discounts operational cost | counterweight to deadpan/cautious leads |
71
72 - *Variance rules* (team-lead enforces at the hiring gate, reading all rosters together): (1) no archetype more than **2× org-wide**, never twice within a team; (2) no two leads hire the same archetype for the same function (two "Detail Hawk reviewers" is duplication, not variance); (3) every team includes its lead's **temperament opposite** — Naomi (curious narrative analyst) → Spreadsheet; Marcus (rigorous citer) → Tinkerer; Renata (clarity-over-cleverness shipper) → Enthusiast; Theo (deadpan minimalist) → Enthusiast or Newcomer; Priya (schema-first PII guardian) → Storyteller; (4) org-wide at least **three** hires tagged playful+bold, because zero leads are; (5) each roster spans at least two temperament axes (serious↔playful, cautious↔bold).
73
74 - *Sizing* (Warren's call: the larger org) — 3–4 hires per track, 18 total, **hard cap 18**. With 13 archetypes at ≤2× org-wide there are 26 slots, so the cap fits without repeats inside any team. Suggested cast, leads may adjust within the rules: 01 (3) → Storyteller, Spreadsheet (opposite), Designated Skeptic; 02 (4) → Adversary, Principled Architect, Tinkerer (opposite), Designated Skeptic; 03 (4) → Detail Hawk (code reviewer), Veteran, Enthusiast (opposite), Systems Cartographer (Go layout / service topology); 04 (3) → Newcomer, Enthusiast (opposite), Minimalist (scope guard); 05 (4) → Systems Cartographer (multi-DB abstraction), Storyteller (opposite), Detail Hawk (DDL nullability — a different function from 03's, so allowed), Spreadsheet (retention windows). Archetypes used twice must have different functions. Leads may still wear a "hat" themselves in three-hats runs rather than hiring for it.
75
76 - *Persona-writing standard* — 90–150 words in `personality`, in this order: temperament (two adjectives and how they show up in a meeting); decision style; **what they specifically push back on**; plausible history (two prior roles, generic employers, no real names); technical background tied to the function; **their own blind spot stated plainly** so the lead can pair against it; how they behave in disagreement. One verbal habit allowed (a recurring question, an analogy style); no repeated catchphrases. Avoid: real people/companies, checkable credentials, stereotypes, sarcasm at a person's expense, profanity, emoji, appearance (that belongs in the image prompt). Two worked examples are included verbatim in `CASTING.md` — the Tinkerer ("a token cache is a coat check that burns the coats every fifteen minutes… pushes back on 'that's the standard approach' whenever nobody can say what the standard protects against; blind spot: novelty bias") and the Principled Architect ("will not accept a control without naming the attack it stops… blind spot: over-specification; changes position when shown evidence, and says so") — so the contrast Warren asked for is concrete.
77
78 - *Orchestration pattern catalog* (full protocols; cost in agent-turns = one hire invocation + reply):
79 - **Adversarial pair** — Proposer drafts; Designated Skeptic writes numbered objections; Proposer revises; lead rules. 3 turns + lead. Artifact: decision with "objections considered". *Use:* search-API authz scoping (`profile:search` vs `profile:read:own`, object-level policy); the `Search()` query shape in `multi-db-strategy.md`.
80 - **Red team / blue team** — Adversary writes an attack tree against one surface; Principled Architect answers each leaf with a control or an accepted risk; lead scores residual risk. 2–3 turns. Artifact: attack-tree table appended to `connector-security.md`. *Use:* the connector token lifecycle only (TTL, at-rest encryption, per-vendor keying, fail-closed). Waste on DAO CRUD or a Dockerfile.
81 - **Three hats** — Tinkerer (optimist), Veteran (pessimist), Spreadsheet/Minimalist (pragmatist) each write ≤300 words in parallel; lead synthesizes. 3 turns + lead. *Use:* multi-DB abstraction (per-driver implementations vs dialect branches vs codegen); Go layout (one binary vs two).
82 - **Rotating devil's advocate** — a standing "Objection" seat that moves per decision so nobody becomes "the negative one". 1 turn per decision. *Use:* Renata's implementation calls (error semantics, context propagation, test doubles). Cheapest pattern; run often.
83 - **Structured written debate** — position → rebuttal → lead synthesis, both positions preserved. 3 turns. *Use:* Naomi's central framing (password baseline: deliberate simplification or contradiction at a passkey company?); Marcus's core claim in `ai-workflow-narrative.md`.
84 - **Newcomer's question** — Newcomer reads a finished hand-off cold and lists what it could not act on without re-deriving; author fixes. 1–2 turns. *Use:* every 05→03 and 02→03 hand-off, the README, `PLANNING.md`. Directly tests the hand-off standard; highest-ROI pattern in the project.
85 - *Never debate what the assignment text fixes* (endpoint paths, JSON bodies). Theo's track: one hire, one Newcomer's-question pass on 03's config surface and 02's secrets inventory, nothing else.
86
87 - *Registry table* — one row per hire, appended by the hiring lead after team-lead's "hiring go": `Slug | Name | Track | Function | Archetype | Temperament tags | Model | Patterns & role | Persona path | Agent def path | Portrait (queued/done)`.
88
89 3. **`.claude/agents/`** (root, new directory) — one `<slug>.md` per hire. Frontmatter: `name`, `description` (function + track + archetype, so the Agent tool picker reads it), `model` (pinned per Marcus's tiers: haiku for research fan-out, sonnet for design/debate/implementation, opus only for the connector token-lifecycle seat), `tools` narrowed where sensible (debate/research hires: no Write/Edit — they return prose to the lead, who writes the artifact), and a `hooks` block with a once-only `PreToolUse` command (matcher `.*`) that runs `show_profile.py --clear` then `show_profile.py --profile <own persona> --root <track-dir>` on the first tool call. Body (plain markdown, no `@` imports — not documented for agent bodies): the persona paragraph copied from its profile, pointers to root and track `CLAUDE.md`, the pattern(s) and role it plays, and the hard rules (`cd <track-dir>` first; no code until Warren's go-ahead; hand-offs by file path; do not spawn subagents — depth cap). Team-lead writes `.claude/AGENT_TEMPLATE.md`; leads copy it per hire.
90
91 3a. **`.claude/settings.json`** (project, new) — `modelSettings.haiku.effortLevel: "low"` so throwaway research fan-out runs cheap; everything else inherits Warren's `high`. Done via the `update-config` skill. This is the only effort lever the harness offers per model; it stands in for Marcus's low/medium/high tiers.
92
93 4. **Root `CLAUDE.md` edits** (at a turn boundary, for cache stability): update *Status* (design docs complete, planning phase in progress, code wiped/paused); add a short *Org, hiring and orchestration* section pointing to `PLANNING.md`, `CASTING.md`, `02-ai-security-architecture/planning-approach.md`, and `.claude/agents/`; state the rules that hires never get CLAUDE.md markers and never spawn subagents. Keep the six existing persona markers as they are.
94
95 5. **`scripts/portrait-queue.sh`** (root `scripts/`, new — the only script; it is ops tooling, not assignment code): a serialized background job that walks `CASTING.md` rows marked `Picture: queued`, generates one portrait at a time via `generate_image.py` with the shared workflow (the ComfyUI box is shared with other agents and takes 2–10 minutes per SDXL image under load — parallel submissions only slow everyone), writes to the pre-declared image path, and flips the row to `done`. Team-lead runs it with `nohup … &` after Phase 2 and never blocks on it.
96
97 ### Phase 1 — Every lead plans, in plan mode (parallel; one message from team-lead to each lead)
98
99 Team-lead sends each lead one brief containing: pointer to `PLANNING.md`, `CASTING
… truncated (20 KB cap){
"file_path": "/Users/warrenthompson/Source/LoginID/PLANNING.md"
}1 # Planning Board 2 3 Thin cross-track coordination board — pointers and status only. The reasoning lives in each track's own files (`<track>/CLAUDE.md`, `<track>/PLAN.md`, deliverables), referenced by relative path. **One writer per row:** each lead edits only its own row; team-lead edits the header, the two Dana rows, and nothing else. Shape and rationale: `02-ai-security-architecture/planning-approach.md` §3. Org plan: `PLAN.md`. Hiring rules and registry: `CASTING.md`. 4 5 Status vocabulary: `not started` · `planning` · `plan drafted — awaiting hiring go` · `hiring go` · `hiring` · `debating` · `plan approved` · `blocked`. 6 7 | Track | Lead | Status | Blocking on | Blocked by | Planning notes | Hires (planned / registered / spawned) | Last updated | 8 |---|---|---|---|---|---|---|---| 9 | Orchestration (root) | Dana Whitfield | Phase 3 — 01, 03, 04 planning work complete; 02 on S8–S10; 05 S1 contract is the critical path; consistency pass next | 05 S1 contract; 02 S9 narrative | — | `PLAN.md`, `LOG.md` | 18 / 18 (cap) | 2026-09-12 | 10 | Cross-track consistency pass (root, Phase 3) | Dana Whitfield | running — fresh Fable session against the 12-seam checklist (`PLAN.md`) plus 05's four items | — | — | `decisions/cross-track-consistency.md` (root, on completion) | — | 2026-09-12 | 11 | 01 Product & Industry Research (incl. Design) | Naomi Voss | plan approved — S1–S6 all done (claims audit, password-baseline debate, connector rationale hardened + cold read, search-authz requirements, UX notes, evaluator-legibility cold read) | — | — | `01-product-industry-research-design/PLAN.md`; `decisions/claims-audit.md`, `decisions/password-baseline-debate.md`, `decisions/rationale-cold-read.md` (S3+S6) all written; Newcomer loan from 04 (`wesley-okonkwo`) closed at 2/2-4 turns, both passed clean | 3 / 3 / 3 | 2026-09-12 | 12 | 02 AI Architecture & Security Architecture | Marcus Ilori | all Stories done except S11 (post-code-go-ahead) — ready for consistency pass | — | — | `02-ai-security-architecture/PLAN.md`; S3/S4/S6/S7 as before; S5 `decisions/ai-workflow-claim-debate.md` (ruled against a general multi-agent-beats-solo claim, for a narrower mechanism-level one); S8 `decisions/second-review-security-docs.md` (required an independence reassignment mid-run — caught before running; 10 accepted findings, 0 declined); S9 `ai-workflow-narrative.md` revised with the org layer, measured costs, and the S5/S8 lessons stated plainly; S10 verified already clean, no edit needed | 4 / 4 / 4 | 2026-09-12 | 13 | 03 Engineering & Delivery | Renata Cole | hires spawned 4/4 — 05's DAO contract accepted, S2 design-ready | Warren's implementation go-ahead | — | `03-engineering-delivery/PLAN.md`; `handoff-03-auth.md` accepted; `decisions/error-semantics.md` + `decisions/context-propagation.md` reconciled against 05's contract; Oren's 4 findings (ErrAlreadyExists reachability, Limit==0 zero-value trap, Upsert/created_at, registration atomicity) all ruled by 05, contract updated with `CreateProfileWithCredential` composite method | 4 / 4 / 4 | 2026-09-12 | 14 | 04 Infra & DevOps | Theo Bergman | five deliverables drafted, Bree/Callum notes folded in | — | — | `04-infra-devops/PLAN.md`; `containerization-design.md`, `ci-pipeline.md`, `local-dev-loop.md`, `secrets-delivery.md`, `observability.md` | 3 / 3 / 3 | 2026-09-12 | 15 | 05 Data Ops | Priya Nandakumar | **plan approved — all six stories closed** | — | — | `05-data-ops/PLAN.md`; 6 stories S1–S6, S1 = Go-shaped contract in `multi-db-strategy.md` (critical path); S4 `migration-approach.md` + S5 retention table unblock 04; `migration-approach.md` drafted for 04 (R1–R5; single-runner requirement is platform-enforced — pg_advisory_lock is unavailable on CockroachDB), pending one objection turn; deep research authorized+run (lead): `05-data-ops/research-cockroachdb-postgres-semantics.md` — one Go package CAN serve Postgres+CockroachDB but needs an engine-aware retry/error seam (CRDB SERIALIZABLE returns client-retryable 40001; sentinel translation must be SQLSTATE-only); corrects R2 of `migration-approach.md`; S3 ruled `05-data-ops/decisions/multi-db-abstraction.md` — per-driver confirmed, but on reviewability not cost (only 2 real conditionals, doc's rejection reasoning corrected); codegen is orthogonal, 03's choice; **requirement on 03: one conformance suite run against every backend** — SQLite `LIKE` is case-insensitive vs Postgres case-sensitive, so the documented parity gap is a correctness gap, not a performance one; S2 ruled `05-data-ops/decisions/search-query-shape.md` — 8/8 objections accepted; adopted principle: **every caller-visible guarantee names an enforcement site (DDL constraint, index, normalization, or conformance test) — one with no site is a hope, not a guarantee**; **S1 Go-shaped contract stable in `05-data-ops/multi-db-strategy.md`** — 03 may start the DAO; S6 cold read (Saoirse, deliberately kept unfamiliar) returned 14 blocking items, all accepted and fixed: `Update`/`Upsert` are FULL REPLACES (nil NULLs the column), `Upsert` conflicts on id only (identity resolution is the connector's problem), error translation revised to structured codes + our own named constraints (SQLite has no SQLSTATE), `ErrInvalidArgument` added for CHECK violations, `is_active` enforcement site named; S5 closed in `05-data-ops/pii-governance.md` — retention windows (24mo direct / 30d cache / 7d orphan, all proposed pending product-legal), deletion mechanism, and the health check that matters: **alert on age-of-oldest-surviving-row per class, since a stopped sweep produces no error at all**; `deletion_log` added to the schema, PII-free by construction; S4 final and delivered to 04 (`migration-approach.md`) — objection turn overturned R2's *reasoning* while the requirement stood: a txn-scoped advisory lock releases between goose files, so it cannot cover a multi-file run; factory mismatch closed with 03 direct (two-party): `dao.Repository` composite exposing `Profiles()`/`Credentials()`/`Methods()`, `dao.New` signature unchanged — cited in S1 | 4 / 4 / 4 | 2026-09-12 | 16 17 ## Sessions (owned by team-lead) 18 19 Each lead is its own top-level Claude Code session in its own tmux window. Use the session name with `SendMessage` (confirm with `ListAgents`); hires are teammates of their lead's session and are reached through the lead. 20 21 | Role | Session name | tmux window | Model | 22 |---|---|---|---| 23 | PM / team-lead (Dana Whitfield) | `org-planning-loginid-takehome` | `1` | fable | 24 | 04 lead (Theo Bergman) | `04-infra-devops-47` | `04-infra` | sonnet | 25 | 02 lead (Marcus Ilori) | `02-ai-security-architecture-a2` | `02-security` | sonnet | 26 | 03 lead (Renata Cole) | `03-engineering-delivery-b7` | `03-engineering` | sonnet | 27 | 01 lead (Naomi Voss) | `01-product-industry-research-design-21` | `01-product` | sonnet | 28 | 05 lead (Priya Nandakumar) | `data-ops-planning-phase` (auto-renamed from `05-data-ops-a3`) | `05-data` | opus | 29 30 ## Service boundaries (owned by 03, read by 04) 31 32 Set by 03 during Phase 1 planning (full reasoning: `03-engineering-delivery/PLAN.md` §4). 33 34 - **Two binaries**, not one: `cmd/api-service` (Q1 DAO + Q2 REST API, client-facing, bearer-token auth) and `cmd/idp-connector` (Q3, outbound-only to vendors) — different threat models/auth surfaces per 02, independent scaling needs, mirrors the assignment's own Q2/Q3 split. 35 - **Package layout:** 36 ``` 37 cmd/api-service/main.go 38 cmd/idp-connector/main.go 39 internal/dao/ # Repository composite interface (Profiles/Credentials/Methods) + postgres/cockroachdb/sqlite implementations 40 internal/model/ # shared structs: UserProfile, UserCredential 41 internal/api/ # REST handlers, router, auth middleware 42 internal/connector/ # /auth, /identity client + handlers 43 internal/config/ # env-var config loading, shared by both binaries 44 internal/app/ # shared bootstrap (route registration, config wiring) both cmd/*/main.go wrap thinly 45 ``` 46 - **Config surface:** environment variables only (12-factor: config lives in the environment, not in checked-in files) — `DB_DRIVER` (postgres|cockroachdb|sqlite), `DB_DSN`, `DB_DSN_FILE` (file-mount path; takes precedence over `DB_DSN` when both are set, per 02's `handoff-04-secrets.md` S7 revision — a DSN's embedded password is as exposed by `kubectl describe`/crash dumps/env-in-child-process as any other secret in that inventory, so `DB_DSN` is now a transitional fallback, not a permanent equal option), `HTTP_ADDR`, `AUTH_JWT_ISSUER` = `https://auth.loginid-takehome.internal` / `AUTH_JWT_AUDIENCE` = `loginid-api-service` (literal values per `handoff-03-auth.md` — not secrets, safe to ship in config), `IDP_ABC_BASE_URL`, `IDP_ABC_CLIENT_ID`/`IDP_ABC_CLIENT_SECRET`. No flags, no config files. 47 - **DAO driver-selection mechanism:** a factory `dao.New(driver, dsn string) (dao.Repository, error)` chosen at startup from `DB_DRIVER`. `dao.Repository` is a composite interface — `Profiles() ProfileRepository`, `Credentials() CredentialRepository`, `Methods() AuthMethodRepository` — matching 05's three-way split in `multi-db-strategy.md`, kept deliberately distinct (not one flat interface) because it's a load-bearing PII/credential-separation control per `05-data-ops/pii-governance.md`: a handler has to reach for the credential accessor on purpose. Each backend package returns one struct satisfying the composite. Factory signature and `DB_DRIVER` selection are unchanged; the three sub-interfaces' method signatures finalize once 05's contract lands. 48 - **Stable for 04 to build against:** the two-binary split, the `cmd/` entrypoint names, the env-var config approach and variable names above, and the `dao.New(driver, dsn)` factory signature. **Not yet stable:** internal DAO implementation details, ORM/query-builder choice, handler-package internals. 49 - **Note for 04, from the Go-layout three-hats ruling (`03-engineering-delivery/decisions/go-layout-debate.md`):** "two binaries" is a *process* boundary, not a claim about host/deploy placement — whether `api-service` and `idp-connector` land on the same node is 04's call, not implied by this section. If they're colocated, `idp-connector`'s outbound-call failure isolation from `api-service` depends on 04's resource limits, not just the process split. Separately, S4 now requires `context.WithTimeout` on every outbound vendor call and a bounded connection pool/backoff on token-refresh retries — not a 04 concern, but flagged here so 04 doesn't assume connector-side backpressure is handled elsewhere. 50 51 ## Dependency order (from `planning-approach.md` §2) 52 53 1. First wave, in parallel: 01, 02, 05 — 05 is on the critical path (it hard-blocks 03's DAO and partially blocks 04). 54 2. 03 split start: scaffolding may begin immediately; DAO once 05's Go-shaped contract is stable; middleware once 02's receiver-shaped auth hand-off lands. 55 3. 04 last, gated on the three inputs named in its row. 56
{
"command": "find . -type f -not -path './.git/*' -not -name '*.png' -not -name '*.json' | sort && echo \"=== profile-gen:start ===\" && grep -c \"profile-gen:start\" CLAUDE.md */CLAUDE.md && echo \"=== code artifacts ===\" && find . -type f \\( -name '*.go' -o -name '*.sql' -o -name 'go.mod' -o -name 'Dockerfile' \\) -not -path './.git/*' && echo \"(end code check)\" && echo \"=== wc ===\" && wc -l */*.md */decisions/*.md *.md 2>/dev/null | sort -n | tail -60",
"description": "Inventory all deliverable files and run mechanical checks"
}./.claude/AGENT_TEMPLATE.md
./.claude/agents/anders-vogel.md
./.claude/agents/beatriz-achterberg.md
./.claude/agents/bree-sandoval.md
./.claude/agents/callum-ferreira.md
./.claude/agents/desmond-okafor.md
./.claude/agents/felix-adebayo.md
./.claude/agents/helena-marsh.md
./.claude/agents/imogen-hale.md
./.claude/agents/ines-dabrowski.md
./.claude/agents/ingrid-solano.md
./.claude/agents/marisol-ferran.md
./.claude/agents/nolan-reyes.md
./.claude/agents/oren-castellan.md
./.claude/agents/saoirse-byrne.md
./.claude/agents/tobias-lindqvist.md
./.claude/agents/tomasz-wrede.md
./.claude/agents/wesley-okonkwo.md
./.claude/agents/yusuf-karadag.md
./.gitignore
./01-product-industry-research-design/api-connector-design-rationale.md
./01-product-industry-research-design/CLAUDE.md
./01-product-industry-research-design/decisions/claims-audit.md
./01-product-industry-research-design/decisions/password-baseline-debate.md
./01-product-industry-research-design/decisions/rationale-cold-read.md
./01-product-industry-research-design/industry-framing.md
./01-product-industry-research-design/personas-use-cases.md
./01-product-industry-research-design/PLAN.md
./01-product-industry-research-design/profiles/desmond-okafor/desmond-okafor.md
./01-product-industry-research-design/profiles/imogen-hale/imogen-hale.md
./01-product-industry-research-design/profiles/naomi-voss/naomi-voss.md
./01-product-industry-research-design/profiles/tobias-lindqvist/tobias-lindqvist.md
./01-product-industry-research-design/research-loginid-company.md
./01-product-industry-research-design/research-oauth-history.md
./01-product-industry-research-design/research-oidc-address-claim.md
./01-product-industry-research-design/ux-notes.md
./02-ai-security-architecture/ai-workflow-narrative.md
./02-ai-security-architecture/api-auth-design.md
./02-ai-security-architecture/CLAUDE.md
./02-ai-security-architecture/connector-security.md
./02-ai-security-architecture/decisions/ai-workflow-claim-debate.md
./02-ai-security-architecture/decisions/connector-token-lifecycle-redblue.md
./02-ai-security-architecture/decisions/search-authz-scoping.md
./02-ai-security-architecture/decisions/second-review-security-docs.md
./02-ai-security-architecture/handoff-03-auth.md
./02-ai-security-architecture/handoff-04-secrets.md
./02-ai-security-architecture/PLAN.md
./02-ai-security-architecture/planning-approach.md
./02-ai-security-architecture/profiles/felix-adebayo/felix-adebayo.md
./02-ai-security-architecture/profiles/helena-marsh/helena-marsh.md
./02-ai-security-architecture/profiles/ingrid-solano/ingrid-solano.md
./02-ai-security-architecture/profiles/marcus-ilori/marcus-ilori.md
./02-ai-security-architecture/profiles/tomasz-wrede/tomasz-wrede.md
./02-ai-security-architecture/research-claude-architecture-best-practices.md
./02-ai-security-architecture/threat-model.md
./03-engineering-delivery/CLAUDE.md
./03-engineering-delivery/decisions/context-propagation.md
./03-engineering-delivery/decisions/error-semantics.md
./03-engineering-delivery/decisions/go-layout-debate.md
./03-engineering-delivery/decisions/test-double-strategy.md
./03-engineering-delivery/PLAN.md
./03-engineering-delivery/profiles/ines-dabrowski/ines-dabrowski.md
./03-engineering-delivery/profiles/marisol-ferran/marisol-ferran.md
./03-engineering-delivery/profiles/nolan-reyes/nolan-reyes.md
./03-engineering-delivery/profiles/oren-castellan/oren-castellan.md
./03-engineering-delivery/profiles/renata-cole/renata-cole.md
./03-engineering-delivery/research-go-best-practices.md
./04-infra-devops/ci-pipeline.md
./04-infra-devops/CLAUDE.md
./04-infra-devops/containerization-design.md
./04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md
./04-infra-devops/local-dev-loop.md
./04-infra-devops/observability.md
./04-infra-devops/PLAN.md
./04-infra-devops/profiles/bree-sandoval/bree-sandoval.md
./04-infra-devops/profiles/callum-ferreira/callum-ferreira.md
./04-infra-devops/profiles/theo-bergman/theo-bergman.md
./04-infra-devops/profiles/wesley-okonkwo/wesley-okonkwo.md
./04-infra-devops/research-infra-devops-best-practices.md
./04-infra-devops/secrets-delivery.md
./05-data-ops/CLAUDE.md
./05-data-ops/decisions/handoff-legibility-05-03.md
./05-data-ops/decisions/migration-approach-objections.md
./05-data-ops/decisions/multi-db-abstraction.md
./05-data-ops/decisions/search-query-shape.md
./05-data-ops/migration-approach.md
./05-data-ops/multi-db-strategy.md
./05-data-ops/pii-governance.md
./05-data-ops/PLAN.md
./05-data-ops/profiles/anders-vogel/anders-vogel.md
./05-data-ops/profiles/beatriz-achterberg/beatriz-achterberg.md
./05-data-ops/profiles/priya-nandakumar/priya-nandakumar.md
./05-data-ops/profiles/saoirse-byrne/saoirse-byrne.md
./05-data-ops/profiles/yusuf-karadag/yusuf-karadag.md
./05-data-ops/research-cockroachdb-postgres-semantics.md
./05-data-ops/research-data-ops-best-practices.md
./CASTING.md
./CLAUDE.md
./LOG.md
./PLAN.md
./PLANNING.md
./profiles/dana-whitfield/dana-whitfield.md
./scripts/portrait-queue.log
./scripts/portrait-queue.sh
=== profile-gen:start ===
03-engineering-delivery/CLAUDE.md:1
CLAUDE.md:1
01-product-industry-research-design/CLAUDE.md:1
05-data-ops/CLAUDE.md:1
02-ai-security-architecture/CLAUDE.md:1
04-infra-devops/CLAUDE.md:1
=== code artifacts ===
(end code check)
=== wc ===
26 01-product-industry-research-design/decisions/claims-audit.md
26 04-infra-devops/ci-pipeline.md
27 01-product-industry-research-design/ux-notes.md
28 03-engineering-delivery/decisions/test-double-strategy.md
34 01-product-industry-research-design/decisions/rationale-cold-read.md
34 03-engineering-delivery/decisions/context-propagation.md
36 02-ai-security-architecture/decisions/ai-workflow-claim-debate.md
37 01-product-industry-research-design/research-oidc-address-claim.md
37 04-infra-devops/secrets-delivery.md
38 01-product-industry-research-design/CLAUDE.md
39 03-engineering-delivery/decisions/error-semantics.md
40 03-engineering-delivery/CLAUDE.md
41 04-infra-devops/CLAUDE.md
43 01-product-industry-research-design/decisions/password-baseline-debate.md
44 04-infra-devops/observability.md
45 02-ai-security-architecture/decisions/search-authz-scoping.md
46 02-ai-security-architecture/decisions/second-review-security-docs.md
46 04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md
52 05-data-ops/decisions/multi-db-abstraction.md
54 01-product-industry-research-design/industry-framing.md
55 PLANNING.md
58 01-product-industry-research-design/personas-use-cases.md
59 02-ai-security-architecture/CLAUDE.md
60 02-ai-security-architecture/decisions/connector-token-lifecycle-redblue.md
61 05-data-ops/decisions/migration-approach-objections.md
61 LOG.md
62 05-data-ops/CLAUDE.md
64 02-ai-security-architecture/handoff-04-secrets.md
65 04-infra-devops/local-dev-loop.md
67 02-ai-security-architecture/ai-workflow-narrative.md
68 04-infra-devops/PLAN.md
70 02-ai-security-architecture/handoff-03-auth.md
73 02-ai-security-architecture/threat-model.md
73 CLAUDE.md
74 05-data-ops/decisions/search-query-shape.md
76 05-data-ops/migration-approach.md
77 02-ai-security-architecture/planning-approach.md
77 05-data-ops/decisions/handoff-legibility-05-03.md
77 05-data-ops/pii-governance.md
79 05-data-ops/research-cockroachdb-postgres-semantics.md
81 01-product-industry-research-design/research-oauth-history.md
82 01-product-industry-research-design/api-connector-design-rationale.md
88 02-ai-security-architecture/connector-security.md
94 01-product-industry-research-design/research-loginid-company.md
97 02-ai-security-architecture/api-auth-design.md
101 03-engineering-delivery/research-go-best-practices.md
103 02-ai-security-architecture/research-claude-architecture-best-practices.md
104 04-infra-devops/containerization-design.md
114 03-engineering-delivery/decisions/go-layout-debate.md
119 02-ai-security-architecture/PLAN.md
121 01-product-industry-research-design/PLAN.md
127 03-engineering-delivery/PLAN.md
148 CASTING.md
151 04-infra-devops/research-infra-devops-best-practices.md
162 05-data-ops/PLAN.md
163 PLAN.md
194 05-data-ops/research-data-ops-best-practices.md
446 05-data-ops/multi-db-strategy.md
4624 total{
"command": "for f in 02-ai-security-architecture/planning-approach.md 02-ai-security-architecture/handoff-03-auth.md 02-ai-security-architecture/handoff-04-secrets.md; do echo \"=================== $f\"; cat -n \"$f\"; done",
"description": "Read 02 planning approach and both hand-offs"
}<persisted-output>
Output too large (46.9KB). Full output saved to: /Users/warrenthompson/.claude/projects/-Users-warrenthompson-Source-LoginID/47cb5423-3173-4f73-9e8a-3e2842e70675/tool-results/bktnj8bm8.txt
Preview (first 2KB):
=================== 02-ai-security-architecture/planning-approach.md
1 # Planning Approach: Claude-Architecture Recommendations for This Team
2
3 Author: Marcus Ilori (02-ai-security-architecture), synthesizing input gathered directly from all four other track owners — naomi-voss (01), renata-cole (03), theo-bergman (04), priya-nandakumar (05) — plus this track's own perspective and `research-claude-architecture-best-practices.md`. Where the tracks disagreed or emphasized different things, I say so rather than averaging them into a mush; where they independently converged (which happened more than I expected), I note that too, because five agents with separate contexts landing on the same answer is itself weak evidence the answer is right.
4
5 Throughout, I distinguish Anthropic-documented guidance from my own judgment applied to this specific project. Where I say "Anthropic recommends," that traces to a specific source in `research-claude-architecture-best-practices.md`. Where I say "my view" or "I'd recommend," that's this project's reasoning, not a citation.
6
7 ## 1. Models and effort levels, by role
8
9 The knobs: model ∈ {fable, opus, sonnet, haiku}, effort ∈ {low, medium, high, xhigh, max}. Warren has already set the orchestrator (team-lead) to fable at high. My reasoning below takes that as fixed and reasons about everyone else relative to it.
10
...
</persisted-output>{
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/planning-approach.md"
}1 # Planning Approach: Claude-Architecture Recommendations for This Team
2
3 Author: Marcus Ilori (02-ai-security-architecture), synthesizing input gathered directly from all four other track owners — naomi-voss (01), renata-cole (03), theo-bergman (04), priya-nandakumar (05) — plus this track's own perspective and `research-claude-architecture-best-practices.md`. Where the tracks disagreed or emphasized different things, I say so rather than averaging them into a mush; where they independently converged (which happened more than I expected), I note that too, because five agents with separate contexts landing on the same answer is itself weak evidence the answer is right.
4
5 Throughout, I distinguish Anthropic-documented guidance from my own judgment applied to this specific project. Where I say "Anthropic recommends," that traces to a specific source in `research-claude-architecture-best-practices.md`. Where I say "my view" or "I'd recommend," that's this project's reasoning, not a citation.
6
7 ## 1. Models and effort levels, by role
8
9 The knobs: model ∈ {fable, opus, sonnet, haiku}, effort ∈ {low, medium, high, xhigh, max}. Warren has already set the orchestrator (team-lead) to fable at high. My reasoning below takes that as fixed and reasons about everyone else relative to it.
10
11 **Orchestrator / PM (team-lead) — fable, high.** Confirmed correct, not just accepted. The orchestrator holds the only cross-track state that matters (scope boundaries, status, "who's blocked on whom") and makes judgment calls that propagate into every track if wrong — a boundary misjudged here (e.g., letting infra silently absorb a decision that was security's to make) costs five agents' worth of rework, not one. That's the single seat in this project where the highest model ceiling earns its cost. I would not go to xhigh/max for this role: xhigh/max effort spends more test-time compute on deeper deliberation per response, and orchestration work here is mostly reading condensed hand-offs and delegating — breadth of correct judgment across many small decisions, not depth on one hard problem. High is the right ceiling for that shape of work.
12
13 **Track owners (the five of us, in planning/design mode) — sonnet, medium-to-high, track-dependent.** This is my own judgment, and it's not uniform across tracks — the five tracks are not equally deep:
14 - Product/research framing (01, Naomi) is synthesis and judgment-call writing — Naomi herself recommended sonnet/medium for her own track, and I agree: the value there is scoping and narrative tightness, not deep technical reasoning, so paying for more depth than the task uses is waste.
15 - Security (02, this track) and the engineering track's design decisions (03) involve genuine multi-step reasoning with real correctness stakes — sonnet/high is the right default there.
16 - Data-ops (05, Priya) is the one track where I'd go above sonnet during *planning*, and Priya made the argument better than I would have: the schema and DAO interface contract is precision-heavy but low-volume, and the cost of a wrong nullable-vs-required field or a wrong interface signature ripples into both 03 and 04. That's asymmetric risk, not asymmetric size — one careful opus/high pass on that specific artifact costs little in absolute tokens and avoids a class of downstream rework nothing else in this project can prevent. This is the same "spend the expensive model where a wrong foundational call is costly to reverse" principle I apply to implementation below, just moved one step upstream to where the contract is actually decided.
17 - Infra (04, Theo) explicitly self-assessed low-to-medium, sonnet, and I agree with his reasoning: it's the smallest-surface-area track, it's consumer-not-producer during planning, and over-resourcing it during the planning phase is the same mistake as over-engineering the infra itself.
18
19 **Throwaway research subagents (fact-gathering, doc lookups, exploring existing code) — haiku, low, or sonnet/low if some judgment is needed.** Naomi's own workflow already does this (haiku/sonnet-low fanned out for industry facts, then one sonnet/medium synthesis pass) and it generalizes: per Anthropic's orchestrator-worker pattern, a subagent's exploration noise never re-enters the coordinator's context — only its condensed output does — so there's no reason to pay for a stronger model on a narrow, verifiable, throwaway task. Fan these out in parallel; that's where cheap-and-fast is unambiguously the right call.
20
21 **Implementation (writing the actual Go code — DAO, REST handlers, connector) — sonnet, medium-to-high; opus only for one narrow piece.** Renata, who owns this work, put it as sonnet/medium-high: once upstream contracts land, this is CRUD/REST/HTTP-client code against clear specs — not novel enough for opus, but not so trivial that low-effort stubs are acceptable when "reads as intentional engineering" is the grading criterion. Tests (table-driven Go, mocked DB/HTTP) are sonnet/medium. I agree with all of that, and it changes my earlier position: I had planned to reserve opus/high for implementing the DAO's multi-database interface, but if Priya delivers the interface as actual Go signatures (see hand-off structure, §2), the hard decision has already been made upstream at opus/high and implementing against it is sonnet work. That leaves the connector's token lifecycle logic (acquire, cache, refresh, never-persist-in-plaintext) as the one implementation surface where I'd still spend opus/high — the correctness bar is a security property, not just a passing test, and the assignment's question 3 is where a subtle bug most directly contradicts this track's own threat model. Anthropic's own internal eval (research doc §3) found an Opus-lead/Sonnet-subagent configuration beat a single-agent Opus baseline by 90.2% at materially higher token cost; the lesson I take is "spend the expensive model where a wrong foundational call is costly to reverse," not "use Opus everywhere."
22
23 **Review/verification passes — high effort always; independence scaled to stakes.** Two positions came in here, and they're compatible rather than contradictory. Renata wants a high-effort review pass on her own output before calling it done, same model — catching a busted interface boundary late costs more than the tokens. That's right for engineering surfaces. My addition, Anthropic-motivated but my own extension: their multi-agent paper's rationale for parallel independent subagents is that separation of concerns "reduces path dependency" — an agent reviewing its own output inherits its own blind spots. Resolving the two explicitly: Renata's same-session high-effort self-review is the *first* pass on everything she produces — it's cheap, it catches the interface-boundary class of bug she's worried about, and there's no reason to skip it. For the security-graded surfaces specifically (credential storage design, token handling, the auth/authz scheme, and the connector code that implements them), a *second* pass in a genuinely independent session is added on top, because those are exactly what LoginID said they're grading the reasoning quality of, and a self-review cannot by construction catch an assumption the author didn't know they were making. For everything else (infra sketches, product prose, routine handlers), the self-review alone is proportionate. This is a take-home POC, not a production deploy, and calibrating review independence to actual stakes is part of the "why," not corner-cutting.
24
25 **Fable reserved narrowly.** I would not spread fable across all five track-owner seats — that's five top-model seats purchased for a take-home whose own cited evidence (the Opus-lead/Sonnet-worker eval) argues for concentrating the strongest model at the lead and running capable-but-cheaper models at the worker tier. The one place I'd consider a second fable seat: a final cross-track consistency pass, run once near submission, checking the five tracks' finished deliverables against each other for contradictions (the exact class of problem Naomi flagged — two tracks independently deciding what `/identity` returns). That's a single, short-lived, high-value use of the top model, not a standing role.
26
27 ## 2. Architecture pattern for planning-phase coordination
28
29 **Default: orchestrator-worker, not peer-to-peer mesh.** This is Anthropic's own documented pattern (research doc §3 — lead agent decomposes, spawns scoped subagents, subagents report back condensed findings, lead synthesizes) and Naomi independently converged on the same recommendation from the product side: five agents cross-talking as a full mesh during planning is more collision risk than value. Status and scope decisions should route through team-lead.
30
31 **Exception: direct track-to-track handoffs on a specific shared decision.** A pure hub-and-spoke model is wrong for one case — two tracks with a direct, narrow dependency on each other's specific decision. This track and data-ops already did this, and Priya asked that it be recorded here as the worked example: we coordinated directly on modeling `user_credential.method` as a lookup table rather than a native enum, because that's a two-party decision (her schema, my method-aware credential-storage design), not a project-wide one, and routing it through team-lead both directions would have added latency for no benefit. Renata, by contrast, explicitly doesn't want peer chatter with 01 or 04 — she wants clean hand-offs from 02 and 05 that she reads once and implements against. Both are right; the difference is whether a genuine two-party decision exists. The rule I'd formalize: point-to-point messaging is fine for a specific, bounded, two-track decision; it must still land in a durable file (see §3) as the source of truth, not live only in chat history; and it doesn't substitute for the orchestrator's overall view of status.
32
33 **Plan mode.** Narrower than I first drafted, after Renata's input. She doesn't want plan mode for her own track — the plan already exists in the assignment text, and if upstream tracks hand her concrete contracts there's nothing left to plan that a plan-mode pass would catch. I think that's correct *conditional on the hand-offs being concrete enough* (see next paragraph); if 05 hands her a prose strategy instead of interface signatures, plan mode before the DAO becomes necessary again because she'd be making design decisions that aren't hers. So the rule: plan mode gates decisions that constrain another track — API contract shapes, schema fields, token formats, the Go interface itself — at the track that owns the decision, before it's declared stable. It does not gate implementation against an already-concrete contract. For a track whose CLAUDE.md brief is already specific and self-contained (per the root file's own stated goal), formal plan-mode approval on top of that brief is redundant — the brief already is the approved plan.
34
35 **Fork vs. fresh scoped agent.** Fork when a task needs the current session's accumulated context and would lose something real by starting clean (e.g., resuming a security review with everything already threat-modeled in-session). Fresh scoped agent when the task is genuinely independent and carrying forward unrelated context would only dilute it (a documentation lookup, a track that hasn't started). This project's five persistent track-owner teammates are, at a coarser grain, already an instance of "fresh scoped agent": each seeded only with the root `CLAUDE.md` plus its own track file, not all five tracks' accumulated history — that's Anthropic's just-in-time retrieval principle (research doc §2, technique 4) applied at the team-structure level, not just the subagent level.
36
37 **Hand-off structure.** Per the research doc (§2/§3), Anthropic's own subagents target roughly 1,000–2,000 tokens of condensed findings passed to a coordinator — never a raw transcript. Concretely here: hand-offs happen through each track's `CLAUDE.md` Status section and named deliverable files, referenced by relative path, exactly as the root file already mandates ("cross-reference other tracks by relative path... rather than restating their content"). A `SendMessage` between tracks signals readiness or asks a targeted question — it is not the transport for the actual content, and Renata was explicit that she wants upstream decisions written somewhere durable, not relayed verbally through team-lead.
38
39 Priya then raised the bar on what "condensed" has to mean for a hand-off into implementation, and I'm adopting her standard project-wide: a hand-off is implementable only if the receiving track can act on it *without re-deriving the sender's reasoning*. For 05→03 that means the actual Go interface signatures (method names, parameter and return types, error semantics), the field-level schema per entity with types and nullability, and explicit notes on what is out of scope for the receiver (e.g., 03 must not invent its own PII/credential separation — that's already decided in 05's governance note). Priya's commitment is to bake that Go-shaped contract directly into `../05-data-ops/multi-db-strategy.md` so it's copy-pasteable, not descriptive. Prose strategy alone forces re-derivation, which is both wasted tokens and a drift risk. The same standard applies to this track's outbound hand-offs: 02→03 needs the token scheme and where it's validated, not a discussion of alternatives; 02→04 needs, per Theo's ask, an explicit secrets inventory (DB credentials, IDP credentials, signing keys) and a never-log list, because he implements delivery and does not set the requirement. Those two are follow-ups for this track once the planning pause lifts — the existing `api-auth-design.md` and `connector-security.md` contain the decisions but not yet in that receiver-shaped form.
40
41 The replies I got to my own bounded question were all in the 300–600 word range — the condensed-finding size the research doc describes — which is mild evidence the pattern holds when the question is scoped tightly.
42
43 **Context and cache management over a multi-hour project.** Two concrete disciplines, both sourced from the research doc's context-engineering section: (1) keep each track's `CLAUDE.md` stable within a working session — edit it at the start or end of a turn, not interleaved mid-task, because an edit to a file currently loaded in context invalidates that cached prefix (research doc §4/§5); (2) when a track's own work legitimately grows long across multiple similar passes (data-ops iterating across DB backends is the clearest case in this project), compact deliberately — write a short settled-decisions summary to a file and start the next pass fresh from that summary rather than carrying the full multi-turn history forward (§2, technique 1). Neither of these is exotic; they're the direct, mechanical application of Anthropic's documented "context rot" mitigation to this project's actual shape of work.
44
45 **Planning dependency order across tracks.** All five tracks described their dependencies independently and the graph they describe is consistent — nobody claimed a dependency someone else denied. Assembled:
46
47 1. **First wave, in parallel: product/research (01), security (02), data-ops (05).** None of the three blocks on another. Naomi's framing depends only on the assignment text; my threat model and auth design needed the assignment text plus the one bounded coordination point with 05 (already resolved); Priya states 05 does not block on 01 or 02. Two nuances worth naming. First, Naomi regards 01 as upstream of everyone, while Priya and Renata both say 01 can run "anytime, independently" — those reconcile: nothing *hard*-blocks on 01, but her `api-connector-design-rationale.md` should be designated the single source of truth for field/endpoint shape so engineering isn't re-deriving it, which is a soft dependency (read it before finalizing, not before starting). The assignment text already specifies the question-2/3 endpoint shapes fairly concretely, so the drift risk is real but narrower than a green-field API would carry. Second, 05 is the critical-path member of this wave — it hard-blocks 03 and partially blocks 04 — so if anything in the first wave gets priority attention, it's the schema and interface contract.
48 2. **Engineering (03) has a split start.** Renata drew a useful distinction: 05 is a hard blocker for the DAO code, but 02 is not a blocker for her early scaffolding — project structure, Go module layout, route wiring, stub handlers can begin while the auth decision is still settling, and she needs 02 only before finalizing the middleware. So 03 starts scaffolding as soon as the first wave is underway and starts the DAO the moment 05's contract is stable — "stable" meaning unlikely to be rewritten, not fully polished. Waiting for perfection would idle the track that owns the largest deliverable.
49 3. **Infra (04) is last**, exactly as Theo described his own role: reviewer/consumer during planning (read the other four tracks' files, flag anything infra-shaped so it isn't silently owned elsewhere), producer only once three specific things exist — 03's service topology, config surface, and DAO driver-selection mechanism; 02's secrets inventory and never-log list; 05's migration tool/approach. Building infra design against boundaries that don't exist yet is fiction dressed as a deliverable, in Theo's words, and I don't have a better way to put it.
50
51 ## 3. Where the shared planning artifact lives
52
53 Three tracks offered a view and they converge. Naomi proposed a single root-level `PLANNING.md` with one section per track, each track editing only its own. Theo wants one owner per section, no shared free-text blob five agents edit concurrently, and suggested a single index file linking to each track's own planning notes — matching the existing directory structure. Renata wants upstream decisions in the owning track's `CLAUDE.md` or a shared design doc she reads once. The common thread is one-owner-per-unit and a thin index, and I'd adopt Theo's shape over Naomi's for two reasons: first, even with an "own section" discipline, five agents live-editing one shared file in a short window is still a collision surface that a table of independent rows avoids more cleanly than prose sections do; second, and more importantly, this project already has a durable decision-record layer — each track's own `CLAUDE.md` plus its deliverable files — and duplicating design content into a second file risks the two drifting apart, which is a worse failure mode than the collision risk it's meant to solve.
54
55 **Recommendation:** a root-level `PLANNING.md`, purely as a thin cross-track coordination board — one table row per track: status, blocking-on, blocked-by, pointer to that track's planning notes, last-updated — plus, per Theo's specific ask, a single "service boundaries" row or short section that 03 owns and 04 reads, so infra isn't polling engineering's `CLAUDE.md` for updates. Short, cheap to write, cheap to skim, genuinely low collision risk because each row has exactly one writer. The actual reasoning — the "why," which is most of what LoginID is grading — stays where it already lives, in each track's own files, referenced by relative path. This keeps the artifact consistent with the root `CLAUDE.md`'s existing rule (cross-reference, don't restate) instead of introducing a second place the same content has to be kept in sync.
56
57 ## Sources
58
59 All Anthropic-attributed claims above trace to `research-claude-architecture-best-practices.md` in this directory, specifically: the orchestrator-worker pattern and the Opus-lead/Sonnet-subagent eval (§3, "How we built our multi-agent research system"), the context-rot/compaction/structured-note-taking/just-in-time-retrieval framework (§2, "Effective context engineering for AI agents"), an
… truncated (20 KB cap){
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/handoff-03-auth.md"
}1 # Hand-off 02 → 03: Auth scheme, validation point, and authorization requirements 2 3 Status: **v2** — updated after S8's independent review of `api-auth-design.md` and `connector-security.md` surfaced fixes that affect this hand-off; both already relayed to you by chat, folded into the durable file here per this project's hand-off standard. Receiver: Renata Cole, `../03-engineering-delivery/`. Written so 03 should be able to scaffold and build the middleware without re-deriving the reasoning in `api-auth-design.md`, `connector-security.md`, or the S3/S4/S8 decision records. Full reasoning, if wanted: `api-auth-design.md`, `decisions/search-authz-scoping.md`, `decisions/connector-token-lifecycle-redblue.md`. 4 5 ## Token scheme 6 7 - **Grant type:** OAuth2 client-credentials. Machine-to-machine only; no end-user login flow for question 2's API. 8 - **Token format:** JWT, signed, asymmetric (RS256 or ES256 — pick one and use it consistently; RS256 is the safer default if your Go JWT library's ES256 support is less mature). 9 - **Claim set (minimum):** `sub` (calling client identity), `scope` (space-delimited, values below), `exp`, `iat`, `aud` (this API, specifically — reject tokens minted for another audience). 10 - **Literal values, filling the "pending 02's final scheme" placeholder in your Service boundaries config surface:** `AUTH_JWT_ISSUER = "https://auth.loginid-takehome.internal"`, `AUTH_JWT_AUDIENCE = "loginid-api-service"`. Both are configuration, not secrets — safe in a `ConfigMap` or committed default, per `handoff-04-secrets.md` row #3. If `cmd/idp-connector` ever needs its own audience value (it doesn't consume this token scheme today — see "Where it's validated" below), that would be a second, distinct `aud`, not a shared one; not needed for this submission. 11 - **TTL:** 5–15 minutes. No refresh tokens — the client re-authenticates with its own client secret on expiry. 12 - **No denylist.** Revocation is handled by TTL shortness, not a stateful lookup. Don't build one; it's not missing, it's a deliberate trade-off already reasoned through in `api-auth-design.md`. 13 - **Verifier must pin the expected algorithm** (whichever of RS256/ES256 you choose) and reject a token whose header claims any other algorithm, including `alg: none` and a downgrade to a symmetric algorithm using the public key as an HMAC secret (RFC 8725 §3.1). Check your JWT library's default behavior — several popular libraries have shipped this vulnerability by not doing this by default. 14 - **Token-endpoint brute-force protection**, separate from the resource-API rate limits below: failed-grant rate limiting and progressive backoff per `client_id` and per source IP on the token-issuing endpoint itself, plus alerting on a sustained run of failed grants against one `client_id`. This protects the endpoint where a caller presents `client_id`/`client_secret`, which none of the resource-API limits below cover. 15 - **Bearer-token replay is an accepted risk, not a gap to close in implementation.** This token has the same possession-equals-authority property as the connector's vendor token (`connector-security.md`); no PoP/binding is expected or required here. Noted so it isn't mistaken for something to "fix" during implementation. 16 17 ## Where it's validated 18 19 - **Middleware at the API edge**, in `internal/api/` per your published service boundary — every request to `cmd/api-service` passes through token validation before reaching a handler. `cmd/idp-connector` does not validate *this* token — but per `connector-security.md` §5 (added by S8 review), `idp-connector`'s own `/auth` and `/identity` handlers need the same JWT middleware pattern, with a **distinct audience** (`aud: idp-connector-service`, not `loginid-api-service`) and its own scope (`connector:identity-lookup`), restricted to internal callers (in this project's shape, `api-service` acting on a caller's behalf) — not the same token, not exposed to arbitrary external callers. Two audiences, one mechanism. 20 - **Key retrieval:** middleware verifies with the **public** key only (JWKS or a fetched public key — see `handoff-04-secrets.md` row #3). The private signing key never touches `api-service`; it lives with the token-issuing function, which is a mode of `api-service` (not a separate binary — see `handoff-04-secrets.md` Assumption 2), scoped by RBAC so the verification code path and the issuing code path don't share Secret access even though they're the same binary. 21 - **Key rotation:** expect a `kid` claim; publish old and new public keys together during rotation overlap. Token TTL (5–15 min) bounds how long that overlap needs to last. 22 23 ## Scope vocabulary (final, from S4 — `decisions/search-authz-scoping.md`) 24 25 | Scope | Authorizes | Does not authorize | Object-level check | 26 |---|---|---|---| 27 | `profile:read:own` | Retrieve a single record by id the caller already holds a legitimate reference to (e.g., a partner resolving a referred user) | Search/query by name or phone fragment; retrieval of a record outside the caller's referral relationship | `target.id` checked against `caller_referral` mapping (data owned by `../05-data-ops/`); out-of-set → reject, logged as a policy denial, distinct from an auth failure | 28 | `profile:read:any` | Retrieve a single record by id, no referral constraint | Search/query by fragment | Requires a `reason_code` from a closed enum (below) on every request, logged with the audit record | 29 | `profile:search` | Query by name/phone fragment across the table | — (this is the broadest scope; there is no broader one) | Result cap enforced; a query that would exceed it unconstrained (e.g., bare wildcard) is rejected outright, not truncated | 30 31 `reason_code` enum for `profile:read:any` (closed, versioned — adding a value is a documented change, not a runtime config toggle): `support_ticket`, `fraud_review`, `kyc_reverification`, `legal_hold`. 32 33 **Object-level policy hook implementation shape:** a function `authorize(sub, scope, target) -> allow|deny`, called after scope validation, before the handler executes the query/retrieve. This is a second check, not a replacement for the scope check — a valid `profile:read:own` token can still fail `authorize()` if the target isn't in the caller's referral set. 34 35 ## Pagination, rate limits, decomposition resistance (requirements — numbers are fixed, mechanism is yours) 36 37 - **Cursor-based pagination only** — no offset-based paging. Default page size 20, hard ceiling 50. 38 - **Per-client-credential rate limits:** 60 req/min for `read:own`/`read:any`; 10 req/min for `profile:search`. 39 - **Cumulative distinct-record-touch counter per caller per rolling 24h window**, tracked independently of per-minute rate — this is the control that catches slow, under-the-cap enumeration (decomposition) that per-request and per-minute limits alone miss. **Applies to `profile:read:any` as well as `profile:search`, unconditionally on whether a `reason_code` is present** — a valid, logged `reason_code` satisfies the per-call `authorize()` policy check but does not exempt the caller from this cumulative cap (S8 review fix: the original wording made this counter a no-op for `read:any`, since that scope requires a `reason_code` on every call by design). The counting-store mechanism (Redis, in-process with periodic flush, whatever fits your stack) is your call; the requirement is that it exists, applies to both scopes, and alerts on threshold, not just hard-blocks. 40 - Sustained near-limit usage on any of the above should alert, not just silently throttle. 41 42 ## Audit-log field list (every search/retrieve call) 43 44 Log these fields, and only these — see `handoff-04-secrets.md`'s never-log list for what must never appear here: 45 46 - `sub` (caller identity) 47 - `scope` used 48 - record id(s) touched (opaque ids, not the PII values) 49 - `reason_code`, when the scope is `profile:read:any` 50 - policy decision (`allow` / `deny`, and if `deny`, whether it was a scope failure or an `authorize()` policy denial — these are distinct signals for detection) 51 - timestamp 52 - request outcome (success/failure/rate-limited) 53 54 Never in this log: PII field values, full JWTs, full request/response bodies. Per `handoff-04-secrets.md`'s new requirement, this log stream itself needs read access distinct from and narrower than whatever can read Secrets — that's 04's mechanism, but don't let the log ship without asking whether that separation exists. 55 56 ## What's not yours to decide (already decided upstream, don't re-derive) 57 58 - **Grant type, token format, and claim set** — decided in `api-auth-design.md`; not open for re-litigation during implementation. 59 - **Scope vocabulary and provisioning bars** — decided in S4 (`decisions/search-authz-scoping.md`); if a real implementation constraint makes one of these unworkable, flag it back to me rather than adjusting it unilaterally. 60 - **The `DB_DSN_FILE` convention** — per `handoff-04-secrets.md`'s S7 revision, this track is asking you to add a file-based alternative to `DB_DSN` (env var becomes a transitional fallback, not a permanent equal option), because a DSN's embedded password is exposed by `kubectl describe`/crash dumps/child-process environment the same as any other secret in that inventory. This is a request into your config surface, not a unilateral change on my part — you decide the exact mechanism (a `_FILE` suffix convention or otherwise), I'm asserting the requirement. 61 - **Password/client-secret hashing** — Argon2id, decided in `threat-model.md` Asset 1; not this hand-off's concern beyond noting it exists. 62 - **PII retention/caching duration for anything the API returns** — `../05-data-ops/CLAUDE.md`'s call, not this track's or yours. 63 - **Whether `read:any` ever gets issued to a real caller** — per S4, the scope stays defined but unissued unless a caller clears the provisioning bar (caller-role table entry, named approver). Don't wire a default grant "just in case." 64 65 ## Open question back to you 66 67 Nothing blocking. One thing worth a quick confirm when convenient: does your `internal/api/` middleware layer have a natural place to run the object-level `authorize()` check as a distinct step from scope validation (e.g., a second middleware or an explicit call inside each handler before the DAO call), or would you rather this track propose a specific Go interface shape for it? Either is fine — say which you'd find more useful and I'll adjust. 68 69 --- 70 Model: sonnet (Marcus Ilori, lead — compiled directly from S4's ruling, no new debate). Turns consumed: 0 hire turns (synthesis of already-ruled decisions per `PLAN.md` S6). 71
{
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/handoff-04-secrets.md"
}1 # Hand-off 02 → 04: Secrets inventory and never-log list 2 3 Status: **v1** — Ingrid Solano's S7 objection turn is answered below and folded into the tables. Awaiting 04's Newcomer (`wesley-okonkwo`) cold read. Receiver: Theo Bergman, `../04-infra-devops/`. Written to the project's hand-off standard: 04 should be able to act on this without re-deriving the reasoning in `threat-model.md`, `api-auth-design.md`, or `connector-security.md`. 4 5 ## Assumptions this hand-off makes 6 7 1. Deployment target is the **design-only Kubernetes lab cluster** 04 names (Warren's decision via Theo, `../LOG.md`). Delivery mechanism is therefore Kubernetes-native `Secret` objects unless 04 chooses an external store; every requirement below is written so it still holds if the mechanism changes. 8 2. Service topology is 03's published boundary (`../PLANNING.md`, Service boundaries): two binaries, `cmd/api-service` (Q1 DAO + Q2 REST API) and `cmd/idp-connector` (Q3), env-var configuration only. Variable names below are 03's; two are marked as pending this track's final scheme. **The token-issuing authorization-server function is a mode of `api-service`, not a third binary** — this take-home's scope doesn't justify a standalone authz server, and 03 has published no third `cmd/` entrypoint. It runs under `api-service`'s own service account; RBAC in row #2 is scoped to that account, restricted (via a distinct Kubernetes Role, not a separate ServiceAccount) so that only the issuing code path — not the token-verification code path in the same binary — can `get` the signing-key Secret. If 03 later decides to split this out as its own binary, this row's RBAC target changes with it; flag that to me if it happens, don't silently re-derive it in 04. 9 3. An **external operator agent** — a human or automated actor outside this project, holding cluster-admin or namespace-admin rights on 04's design-only lab cluster, who applies Kubernetes manifests to it — is a privileged actor outside this service: anyone who can apply a manifest can read any Secret it mounts. Consequence for 04: manifests reference Secrets by name and never carry values; values are set out-of-band. This is a named trust boundary in `threat-model.md`, not a silent assumption; nothing further about who that operator is needs to be re-derived from 04's own files to act on this hand-off. 10 4. Nothing is deployed and nobody contacts the cluster or its operator during the take-home. This document describes what *would* be delivered, not what has been. 11 12 ## Secrets inventory 13 14 | # | Secret | Used by | Injection requirement (02 sets it) | Rotation expectation | Kubernetes-native note (04's mechanism) | Never-log | 15 |---|---|---|---|---|---|---| 16 | 1 | Database credentials, carried inside `DB_DSN` | `api-service` | Runtime-injected; never in source, image, or committed config. **Required, not preferred: file mount via a `DB_DSN_FILE` convention** (03 to add alongside `DB_DSN`), because a DSN's embedded password is exposed by `kubectl describe`, crash dumps, and child-process environment exactly as much as any other secret in this table — there is no citation that makes `DB_DSN` a lesser case, so this track is not treating it as one | On credential rotation; service restart or reload acceptable | `Secret` mounted read-only as a file, read via `DB_DSN_FILE`; env-var `DB_DSN` is a fallback only until 03 ships the file-based convention, not a permanent equal option | Yes — the DSN contains the password; log the driver name and host at most | 17 | 2 | JWT signing **private** key (RS256/ES256), per `api-auth-design.md` | Token-issuing authorization-server component only — **not** `api-service`, which verifies with the public key | Runtime-injected as a **file**, never an env var; asymmetric so the API never holds it | Key rotation with a `kid` claim; publish old and new public keys together during overlap; TTL of tokens (5–15 min) bounds the overlap window | `Secret` mounted read-only as a file; restrict `get`/`list` on this Secret to the issuer's service account via RBAC | Yes | 18 | 3 | JWT **public** key / JWKS | `api-service` middleware | Not a secret; may ship in config or be fetched from the issuer | Follows #2 | Plain `ConfigMap` or fetched URL; listed here so 04 does not over-protect it | No (but never log full tokens — see below) | 19 | 4 | API caller `client_secret`s (OAuth2 client-credentials) | Authorization server (verifies); callers hold their own copy | **Stored hashed** (Argon2id, same floor as `threat-model.md` Asset 1), never plaintext at rest; issued once at provisioning and not recoverable | Per-client rotation on demand; revoke by deleting the hash | Lives in the issuer's datastore, not a Kubernetes `Secret`; 04 delivers only that datastore's credentials (#1-shaped) | Yes | 20 | 5 | Connector's own vendor registration: `IDP_ABC_CLIENT_ID`, `IDP_ABC_CLIENT_SECRET` (and the XYZ pair if a second vendor is configured) | `idp-connector` | Runtime-injected; never in source or committed config; `client_id` is low-sensitivity but travels with the secret and gets the same handling | Per vendor's policy; expect manual rotation | One `Secret` per vendor so rotating ABC never touches XYZ; file mount preferred | Yes for the secret; keep the id out of logs too | 21 | 6 | Vendor `access_token` (obtained via `/auth`, used for `/identity`) | `idp-connector`, in memory | **Not a deployed secret** — transient. If cached at all: TTL ≤ vendor expiry, encrypted at rest with #7, keyed per vendor account (`connector-security.md` §1) | Expires on its own; never persisted across restarts | No `Secret` object; if 04 provisions a cache, it must be encrypted using #7 and reachable only from `idp-connector` | Yes — never, including error paths | 22 | 7 | Data-encryption key (KEK) for envelope encryption of any TOTP shared secrets and any cached vendor tokens | `api-service` (TOTP) — **not a committed feature of this submission**; `user_credential.method` is modeled as an extensible lookup table (`../05-data-ops/multi-db-strategy.md`) that supports TOTP as a *possible* value, but no TOTP implementation is planned unless 03 builds one. `idp-connector` (token cache) — **also not committed**: S3's red/blue on the connector token lifecycle (in progress) is deciding whether the connector caches vendor tokens at all; current direction (Felix Adebayo's alternatives turn) is "no cache by default, fetched fresh per `/identity` call," which would remove this use case too. This row is provisioned only if either feature materializes — it is a contingency the inventory names in advance, not a live requirement for 04 to build now | Runtime-injected as a file; the KEK never leaves the process that decrypts | Rotation by re-wrapping DEKs; never requires re-hashing passwords (those are hashed, not encrypted) | `Secret` (file mount) or an external KMS if 04 prefers — the requirement is only that a KEK exists and is not in source | Yes | 23 | 7a | Credential to reach the external KMS, **only if 04 exercises the KMS alternative in row #7** (a role, API key, or client cert — whichever the chosen KMS uses) | Whichever binary holds the KEK reference (#7) | Same discipline as every other row here: runtime-injected, never in source, never provisioned ad hoc outside this table | Per the KMS provider's own rotation mechanism | Cloud-KMS access is typically workload-identity-based (no static credential to store) rather than a `Secret` object; if the chosen KMS instead requires a static credential, it gets its own `Secret` under the same RBAC discipline as row #2 | Yes | 24 | 8 | TLS serving private keys for `api-service` and `idp-connector` | Ingress / each binary, per 04's termination choice | Runtime-injected; TLS 1.2+ only; certificate validation never disabled in any environment (`connector-security.md` §2) | Per certificate lifetime; automated issuance preferred | `kubernetes.io/tls` `Secret`; issuance mechanism is 04's call | Yes | 25 | 9 | End-user's vendor password (`POST /auth` request body) | `idp-connector`, transient | Never persisted, never cached, held only for the vendor call | n/a | Nothing for 04 to deliver; listed so nobody creates a place for it | **Yes — under no circumstance, including stack traces** | 26 27 Names marked pending in 03's boundary: `AUTH_JWT_ISSUER` / `AUTH_JWT_AUDIENCE` are configuration, not secrets; this track will confirm their final values in `handoff-03-auth.md`. They are listed here only so 04 does not classify them as secrets. 28 29 ## Kubernetes-specific requirements (04 implements; 02 sets) 30 31 - **Secrets at rest in etcd are base64, not encrypted, by default.** Either enable encryption at rest for the `Secret` resource type, or source values from an external store that never writes plaintext to etcd. One or the other; the design must say which. 32 - **RBAC:** no service account may `get`/`list` Secrets it does not mount. The operator agent's apply rights are a documented trust boundary, not a hole to close in this take-home — but the design should note that its scope could be narrowed to a namespace. 33 - **Manifests carry references, never values.** Any manifest in the repository contains Secret *names*; values are provided out-of-band. This is what keeps assumption 3 honest. 34 - **Prefer file mounts over env vars** for #2, #5, #7, #8. #1 is env-var only because 03's config surface is env-var only; if 03 accepts a `_FILE` suffix convention, move it. 35 - **No secret in an image layer, build arg, or CI log.** CI redaction is 04's mechanism; the requirement is that a build log can be published without exposing anything in this table. 36 - **Audit/access logs (the output of the never-log list below) are readable only by an incident-review role, distinct from and narrower than general operator access.** `sub`, scope, record id, and timestamp are enough on their own to reconstruct who looked up whom — that log stream needs its own read restriction, separate from whichever service account can `get`/`list` the Secrets in this table. Mechanism (log-pipeline RBAC, a separate log sink with its own access control, etc.) is 04's call; the requirement is that "can read Secrets" and "can read the audit trail" are not the same grant. 37 38 ## Never-log list (applies to every component, every environment, every log level, including error paths and stack traces) 39 40 1. End-user vendor password (#9). 41 2. Vendor `access_token` (#6) — and any `Authorization` header value, inbound or outbound, in full. 42 3. PII field values from `user_profile` or from `/identity` responses: name, phone, `street_address`, `locality`, `region`, `postal_code`, `country`. Log the *fact* of access — caller `sub`, scope used, opaque record id, timestamp, status — never the values. 43 4. Raw vendor error response bodies (they can echo submitted PII or partial credentials). Log status code and vendor error code only. 44 5. JWT signing private key (#2) and any JWT in full — log `sub`, `aud`, `exp`, and `kid` if needed, never the token. 45 6. API caller `client_secret`s (#4), in plaintext or hashed. 46 7. `DB_DSN` (#1) or any connection string containing credentials. 47 8. Full request bodies of `POST /auth` and `POST /identity` — both contain items above by construction. 48 9. Search query parameters containing a name or phone fragment (`api-auth-design.md`, hardening) — they are PII; redact or hash before any access log, including proxy and ingress logs 04 controls. 49 50 ## What 04 does not decide (already decided upstream) 51 52 Token TTLs and claim set (`api-auth-design.md`); scope vocabulary (`handoff-03-auth.md`, pending); password and client-secret hashing (`threat-model.md` Asset 1); whether the connector caches vendor tokens (`connector-security.md` §1 — it may, under the conditions in #6); PII retention windows (`../05-data-ops/pii-governance.md`). 53 54 ## S7 objection turn — Ingrid Solano, resolved 55 56 All four objections accepted; no decline. 57 58 1. **Authorization-server ownership gap.** Accepted. Assumption 2 now states the authz-issuing function is a mode of `api-service`, scoped by a Role narrower than the token-verification code path in the same binary — not a phantom third deployable. 59 2. **Missing KMS-access-credential row.** Accepted. Added row #7a with the same columns as every other row, rather than leaving it as a footnote inside #7. 60 3. **Env-var exception for `DB_DSN`.** Accepted — the objection is correct that "prefer, if 03 allows" was this track declining a call it owns. Upgraded to a requirement: a `DB_DSN_FILE` convention, with the env var as a transitional fallback only. Carried into `handoff-03-auth.md` as an ask to 03. 61 4. **No RBAC on audit-log read access.** Accepted. Added a requirement under Kubernetes-specific requirements that Secret-read and audit-log-read are distinct grants. 62 63 --- 64 Model: sonnet (lead, ruling) / sonnet (Ingrid Solano, objection turn). Turns consumed: 1 (single objection-turn pattern, per `CASTING.md` §5). 65
{
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/connector-security.md"
}1 # IDP Connector Security Design — Question 3
2
3 Owner: Marcus Ilori (`02-ai-security-architecture`). Covers the third-party identity-provider connector (generic vendors "ABC"/"XYZ") exposing `/auth` and `/identity`, per the assignment. Implementation target: `../03-engineering-delivery/CLAUDE.md`.
4
5 ## Contract recap (from the assignment, verbatim in `../CLAUDE.md`)
6
7 - `POST /auth` — body `{"username": "<string>", "password": "<string>"}` → returns an `access_token`.
8 - `POST /identity` — body `{"phone": "<string>", "name": "<string>"}` → returns PII (name, phone, address fields).
9
10 Read literally, this connector is a thin proxy in front of ABC/XYZ: it takes vendor credentials, gets a vendor token, and uses that token to pull PII from the vendor. Every security question here is "what does this service do with a vendor secret and vendor PII while it's in our custody," since we don't control the vendor's own security posture.
11
12 ## Threat surface specific to this connector
13
14 This extends the third-party-token row in `threat-model.md`; this document is the concrete design against those threats.
15
16 ### 1. What happens to the vendor `access_token` once retrieved
17
18 - **Never logged.** The `/auth` request body contains a vendor password; the response contains a bearer token. A naive "log the request and response for debugging" habit puts both in the same log line — this is called out explicitly because it's the single most common way a connector like this leaks a credential in practice, not a hypothetical.
19 - **No persistent or cached token, by default.** Revised by the S3 red/blue run (`decisions/connector-token-lifecycle-redblue.md`): the token is fetched fresh from `/auth` for each `/identity` call, held only inside that single request's execution scope, and discarded when the call returns — not written to any cache or datastore as a matter of course. Caching is an explicitly-justified exception requiring its own threat re-review if latency or vendor rate limits ever force the question; if that exception is ever opened, it must satisfy: (a) cache key derived from an immutable, vendor-issued identifier (our tenant id + vendor id + the vendor's own opaque subject id) — never from request-supplied `name`/`phone`, which are attacker-influenced; (b) TTL no longer than the vendor's own token expiry, encrypted at rest (envelope encryption via KMS, mechanics owned by `../04-infra-devops/CLAUDE.md`); (c) if the KMS/envelope key is unreachable at write or read time, fail closed to "no cache, fetch live" for that call — never to an unencrypted write and never to a hard connector failure.
20 - **Fetch-use-zeroize boundary, stated explicitly so it survives implementation:** the token variable's scope runs from the `/auth` response to the immediately following `/identity` call and no further; it is not passed up the call stack beyond the vendor-call function, and it is not reused across a retry. **A retried `/identity` call re-fetches via `/auth`; it never reuses a token held from a prior attempt** — otherwise the "one request" exposure window `§4`'s retry/backoff allowance quietly becomes "however long the retry policy runs." Go gives no guaranteed memory zeroing without unsafe/cgo, so a crash dump taken mid-call could still capture the token in the vendor-call function's stack; that residual is accepted given the window is now one function call, not a cache TTL (core-dump suppression at the runtime level is a `../04-infra-devops/CLAUDE.md` mechanism, requirement recorded here, not owned here). The token field is excluded from any generic request/response dump used in error handling — the same log-hygiene discipline below applies to panic traces and debug dumps, not just application logs.
21 - **The outbound HTTP client to the vendor must construct a fresh, isolated `Authorization` header per request, never inherited from connection reuse.** Connection pooling/keep-alive is the normal default in any HTTP client library; a pooled client that reuses a request-context object or fails to reset the auth header per call can attach request A's vendor token to request B's outbound call — the same cross-tenant leak as a cache-key collision, with no cache required. This is a required implementation discipline, not an assumption left implicit.
22 - **Scoped narrowly if the vendor supports it** — request only the scope needed for `/identity`, not a broad token, so a leaked token's blast radius is bounded by the vendor's own authorization model as well as ours.
23 - **Not returned to our own API callers.** The vendor `access_token` is an internal implementation detail of this connector; question 2's API callers never see it. If a caller needs to know "identity lookup succeeded," return our own success/failure signal, not the vendor's token or vendor's raw response.
24
25 ### 2. How `/identity` calls are authenticated to the vendor
26
27 - Standard bearer-token usage: the vendor `access_token` from `/auth` presented as an `Authorization: Bearer` header to the vendor's identity endpoint (or whatever the vendor's own contract specifies — this connector adapts to the vendor, not the reverse).
28 - **Token refresh/re-auth on expiry** handled by the connector transparently — a caller of our `/identity` endpoint should not need to know or manage vendor token lifecycle.
29 - **Vendor calls over TLS only**, certificate validation never disabled, including in local/dev environments (repeating the threat model's point because it's the most common place a "temporary" dev bypass ships to production unnoticed).
30 - **Our own credentials to call the vendor's `/auth`** (this connector's client id/secret with ABC/XYZ, if the vendor requires connector-level registration in addition to the end-user's own username/password) are runtime-injected secrets, never in source or config committed to the repo — requirement set here, mechanism owned by `../04-infra-devops/CLAUDE.md`.
31
32 ### 3. What this service should and shouldn't log or persist
33
34 | Data | Log it? | Persist it? |
35 |---|---|---|
36 | End-user's vendor username | No (identifier, but low sensitivity relative to password — still avoid in plaintext logs; use it only for correlation via a hash if audit trails need it) | Only if a legitimate business reason exists; not by default |
37 | End-user's vendor password (`/auth` request body) | **Never, under any circumstance, including error paths and stack traces** | **Never** |
38 | Vendor `access_token` | **Never** | Only as short-TTL encrypted cache per above; never in plaintext, never in application logs |
39 | PII returned by `/identity` (name, phone, address fields) | Never the field values; log only the *fact* of a lookup (caller identity, timestamp, success/failure, which record was resolved by opaque id) | Retention/caching policy for this PII is `../05-data-ops/CLAUDE.md`'s call — this track's requirement is that whatever is persisted follows the same encryption-at-rest and access-control bar as `user_profile` data, no weaker because it arrived via a connector rather than direct input |
40 | Vendor error responses | Log status/error-code only; do not log raw vendor response bodies by default, since a vendor error payload can itself echo back submitted PII or partial credentials | N/A |
41
42 ### 4. Failure handling as a security control, not just reliability
43
44 - **Vendor auth failures must not leak which part of the credential was wrong** (username vs. password) in whatever this connector surfaces upward — same user-enumeration concern as the threat model's credential-stuffing row, now facing outward at whoever calls our `/auth` passthrough.
45 - **Retry/backoff on vendor calls** should be rate-limited on our side independent of the vendor's own limits, so a compromised or careless caller can't use our connector to hammer the vendor (which is both an availability risk to us and a way to get our connector's vendor credentials rate-limited or banned).
46 - **Circuit-breaking**: if the vendor is failing or behaving anomalously (e.g., returning tokens for identities that don't match the request), fail closed — do not fall back to a cached or stale identity result silently, since that risks serving stale PII as if freshly verified.
47
48 ### 5. Authentication and authorization of calls *into* this connector
49
50 Found by Ingrid Solano's S8 independent review — §1–§4 above are entirely about protecting the *vendor* token and the *vendor* call; none of them state who is allowed to call *our* `/auth` and `/identity` in the first place. Fixed here rather than left as a silent assumption:
51
52 - **Callers of `cmd/idp-connector`'s own `/auth` and `/identity` must be authenticated**, using the same OAuth2 client-credentials → JWT bearer mechanism as `api-auth-design.md`, but with a **distinct audience** (`aud: idp-connector-service`, separate from `loginid-api-service`) and its own scope (`connector:identity-lookup`) — this is a different threat model and a different, smaller caller set (internal services, not question 2's broader API clientele), so it gets its own audience rather than reusing the search API's. Only a service that legitimately mediates an end-user or business flow needing vendor identity data (in this project's shape, `cmd/api-service` acting on a caller's behalf) should hold this credential — it is not exposed to arbitrary external callers.
53 - **Why this matters concretely:** without it, an unauthenticated caller could hit our `/auth` passthrough with arbitrary vendor credentials all day and use this connector as exactly the credential-testing oracle §4's rate-limit control is meant to prevent — but "rate-limited per calling-API-client" has nothing to bind "per calling-API-client" to if no caller identity is required to reach the endpoint at all.
54 - **Audit logging** for calls into this connector follows the same discipline as §3's table: caller `sub`, scope, timestamp, outcome — never the vendor credentials or PII in the request/response bodies.
55
56 ### 6. What a vendor token authorizes at `/identity` — an assumption, stated explicitly
57
58 Also found by S8 review. This design's token-handling discipline (§1) is entirely about *custody* of the vendor token — encryption, memory scope, never-log. It says nothing about what the token is allowed to *do*: specifically, whether a vendor `access_token` obtained from one person's `/auth` call authorizes an `/identity` query for a *different* person's `name`/`phone`, or only for the token-holder's own identity.
59
60 This connector cannot enforce that boundary itself — the assignment's generic `/identity` contract (`{"phone": "...", "name": "..."}`, no scoping field) puts that decision entirely on the vendor's own API. **Stated assumption, not a resolved control:** this design assumes a real ABC/XYZ-shaped vendor binds `/identity` queries to the authenticated principal at the vendor side (i.e., the vendor's own API rejects a query for an identity other than the token-holder's). If that assumption doesn't hold for a given real vendor, this connector becomes a lookup oracle for anyone who can obtain one valid vendor token, and no control in §1–§5 touches that risk, because none of them govern token *authority* rather than token *custody*. This is the same class of question as the search API's object-level authorization (`api-auth-design.md`, S4) and belongs on `../01-product-industry-research-design/CLAUDE.md`'s list when evaluating which real vendor ABC/XYZ represents — this track cannot resolve it without knowing a concrete vendor's actual token semantics.
61
62 ## What this design does not resolve
63
64 - Data retention duration for cached `/identity` results — `../05-data-ops/CLAUDE.md`.
65 - Which secrets manager holds this connector's own vendor credentials — `../04-infra-devops/CLAUDE.md`.
66 - Vendor selection/evaluation (which real-world providers "ABC"/"XYZ" might represent) — that's product framing in `../01-product-industry-research-design/CLAUDE.md`, not a security question.
67
68 ## Sources
69
70 - OWASP API Security Top 10 2023, API7 (Server Side Request Forgery) and API8 (Security Misconfiguration) — both relevant to a service that makes outbound calls to a third party on a user's behalf: https://owasp.org/API-Security/editions/2023/en/0x11-t10/
71 - OWASP Logging Cheat Sheet (what not to log — credentials, tokens, PII): https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
72
73 ## Attack-tree table — connector token lifecycle (S3 red/blue, 2026-09-12)
74
75 Full positions, rebuttals, and ruling: `decisions/connector-token-lifecycle-redblue.md`. Table shape per `planning-approach.md` Appendix A.
76
77 | # | Leaf (attack path) | Precondition | Likelihood | Impact | Blue response: Control *or* Accepted risk | Owner | Residual | Source |
78 |---|---|---|---|---|---|---|---|---|
79 | 1 | Cache-key confusion → cross-tenant token/PII leak via request-derived cache key | Persistent cache (eliminated by default posture below) | M | H | **Eliminated by design:** no cache by default (Felix Adebayo, alternatives seat) — no persistent cache means no key to collide on. **Control, exception-path only:** if caching is ever re-opened, key must derive from immutable vendor-issued ids, never request-supplied `name`/`phone`. **Control (this run, Tomasz's rebuttal):** the connection-reuse variant of this leaf survives even with no cache — a pooled HTTP client that doesn't reset `Authorization` per call can attach one request's token to another. Closed with a required control: fresh, isolated auth header constructed per outbound request, never inherited from connection reuse. | 03 (client construction; cache-key derivation if exception ever opened) | **L** — storage variant eliminated; connection-reuse variant closed by an explicit, checkable implementation requirement | OWASP API8:2023; judgment (connection-reuse variant) |
80 | 2 | Fail-open on KMS unavailability during a caching window | Persistent cache with KMS-backed encryption at rest (eliminated by default posture) | M | H | **Eliminated by design:** no cache by default removes the write-time KMS dependency from the hot path. **Control, exception-path only:** if caching is ever re-opened, KMS-unreachable fails closed to "no cache, fetch live," never to an unencrypted write. Tomasz conceded this leaf outright — no hole found. | 03/04 (KMS mechanism is 04's) | **L** | NIST SP 800-57 |
81 | 3 | Vendor token replay — no proof-of-possession/session binding once decrypted for use | Token exfiltrated by any other means within its exposure window | M | H | **Accepted risk.** Bearer tokens under RFC 6750 are possession-equals-authorization by design; PoP/binding (DPoP, RFC 9449; mTLS-bound tokens, RFC 8705) needs vendor-side support this project cannot assume for a generic ABC/XYZ vendor the assignment never specifies. Acceptable *for this system* because fetch-per-call bounds the exposure window to one request's execution scope — no standing cached credential to steal. **Tightened per Tomasz's rebuttal:** the boundary is stated explicitly — fetch → use in the immediately following `/identity` call → zeroize; a retried `/identity` call re-fetches via `/auth` and never reuses a token from a prior attempt, so the accepted window cannot silently stretch to the length of the retry/backoff policy. | — (re-review trigger: a PoP-capable vendor surfacing in 01's research) | **M** — accepted, not eliminated; a real vendor-side constraint, not a design gap, but still "possession = authority" for the fetch's lifetime | RFC 6750 §5; RFC 9700 §2.5 |
82 | 4 | Process-memory/crash-dump exposure of the plaintext token before encryption | Attacker has read access to a crash artifact or swap on the host | L | M | **Control:** token field excluded from any generic request/response dump used in error handling (panic traces, debug dumps — same discipline as application logs); token variable scoped to the narrowest function possible, never passed up the call stack beyond the vendor-call function. **Residual accepted:** Go gives no guaranteed memory zeroing without unsafe/cgo, so a crash dump taken mid-call could still capture it; acceptable given the window narrowed from a cache TTL to one function call (Felix's alternatives turn). Core-dump suppression at the runtime level is a 04 mechanism, requirement recorded, not resolved here. | 03 (code discipline) / 04 (runtime config) | **L** — narrowed from a standing cache exposure to a single function-call window | OWASP Logging Cheat Sheet |
83 | 5 | Connector used as a credential-stuffing oracle against the vendor (rate-limit granularity unstated) | No per-identity/per-IP rate-limit granularity stated in the original design | M | M | **Control:** rate limit keyed per calling-API-client *and* per source IP, with a stricter threshold on vendor-auth *failure ratio* specifically (credential stuffing shows as a high failure ratio before high volume), paired with exponential backoff per identity attempted, not just per client — closes the granularity gap without adding a new requirement beyond what §4 already gestured at. | 03 (rate-limit logic) / 04 (shared rate-limit store or edge mechanism, if enforced at a gateway) | **L** | OWASP API4:2023; NIST SP 800-63B §5.2.2 |
84
85 Rules of use followed: Tomasz ranked and handed over his top five before Helena answered (per `CASTING.md` §5 ⟨02⟩); Felix's alternatives turn sat between red and blue and eliminated leaves 1's storage variant and 2's substrate by design change rather than control; Helena answered every leaf with a control or a justified accepted risk; Tomasz's rebuttal turn on the top three surfaced one unaddressed variant (leaf 1's connection-reuse case) and one boundary needing explicit statement (leaf 3), both resolved without reopening the ruling; Marcus assigned Residual with the justification in each row. No Residual **H** — none of the five leaves cleared that bar after blue's response and the rebuttal-turn tightening, so no follow-up Story is required; leaf 3's **M** is the one residual worth Warren's attention as a genuine accepted risk, not a gap.
86
87 ---
88 Model: sonnet (Tomasz Wrede, red; Felix Adebayo, alternatives; Helena Marsh, blue) / sonnet (Marcus Ilori, lead, ruling and residual scoring). Turns consumed: 5 (red top-five, alternatives, blue, rebuttal, lead residual scoring) — matches the 4-turns-plus-lead budget in `PLAN.md` §3.
89 {
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/api-connector-design-rationale.md"
}1 # API & Connector Design Rationale
2
3 Prepared for the `01-product-industry-research-design` track. See `../CLAUDE.md` for the full assignment text and `./CLAUDE.md` for this track's scope. Written so `../03-engineering-delivery/CLAUDE.md` can implement against it directly, without re-deriving the reasoning — that's this document's explicit job per the track brief. Builds on `industry-framing.md` and `personas-use-cases.md` in this directory; read those for the "why," this document is the "so build it this way."
4
5 **AI tooling note:** produced by Claude Code (Sonnet 5), track-owner subagent persona Naomi Voss, as a design-reasoning pass — no new external research; synthesizes the assignment text plus this track's prior work.
6
7 This document covers three surfaces in the order the assignment presents them: the `user_profile`/`user_credential` data contract (Q1), the search/retrieve REST API (Q2), and the third-party IDP connector (Q3).
8
9 ## 1. Data contract: `user_profile` and `user_credential`
10
11 **Why two separate entities, not one row:** the assignment names them separately, and that separation is doing real work, not just organizing fields. `user_profile` is PII a service holds *about* a person (name, address, phone) — data with governance obligations (retention, right-to-deletion, breach-notification exposure) attached to `../05-data-ops/CLAUDE.md`'s scope. `user_credential` is a proof-of-identity mechanism the service uses *to authenticate* that person — a username, an auth `method`, and (for the password method) a secret. Conflating them into one table would mean every profile read touches the credential store and vice versa, which is exactly the wrong blast radius for a credential breach.
12
13 **Why `user_credential.method` matters as a design decision, not just a column:** per `industry-framing.md` §2, this field is the seam where the assignment's deliberately simple password baseline becomes extensible toward what LoginID actually sells (FIDO2/passkey, `method = "webauthn"`) without a schema rewrite. Concretely: `method` should be treated as a discriminator that governs which other fields on the row are meaningful — a `"password"` row has a password hash; a future `"webauthn"` row would have a public key and credential ID instead, not a password field left null by convention. Whether that's modeled as one wide table with nullable method-specific columns or a base table plus method-specific side tables is an implementation choice for `../03-engineering-delivery/CLAUDE.md` and `../05-data-ops/CLAUDE.md` — the product requirement this document is asserting is only that **`method` must be a real discriminator with room to grow, not a fixed enum assumed to always be `"password"`.**
14
15 **One relationship, not two independent tables:** a `user_profile` and its `user_credential`(s) need a stable foreign key (one profile can plausibly have more than one credential — a password today, a passkey added later, without replacing the old one until migration is complete). Designing for 1-to-many from the start avoids a breaking migration the day a second auth method is added.
16
17 ## 2. REST API: search and retrieve
18
19 **Endpoint shape, from the personas in `personas-use-cases.md`:**
20
21 - `GET /profiles/{id}` — exact retrieval by primary key. Serves any caller who already has the ID (the common, low-risk case).
22 - `POST /profiles/search` — the search surface Persona 2 (support/ops analyst — the internal user who looks up a customer record by name or phone during a support ticket; see `personas-use-cases.md`) actually uses day to day. **Resolved as `POST`, not `GET` with query params**, for two product-level reasons, not just implementation convenience: (1) the search body needs to carry partial-match flags and an explicit `expand` toggle — a boolean field in the request body, see the Contract Summary below for its exact shape — alongside the query fields, which is awkward to express and easy to get wrong as bare query params; (2) `GET` query strings land in access logs and browser history by default, and a phone number or partial name is PII — `POST` keeps the query payload out of the URL and lines up with where this document already requires every search call to be an audit-log event. This is the endpoint that needs the most deliberate authz design, because "search by phone" is simultaneously the legitimate support workflow and the shape of a scraping attack.
23
24 **Why search and retrieve are named as separate concerns in the assignment, and should stay separate in the API design:** retrieval-by-ID is nearly risk-free — if a caller has a valid ID, they likely already have a legitimate reason to look at that record. Search is different: it's a fuzzy-match query over a PII store, which means the API surface itself is the control point. Design implications this document asserts (mechanics belong to `../02-ai-security-architecture/CLAUDE.md`):
25
26 - Search results should default to **masked/partial PII** in the response, with full-field detail requiring either a distinct, more tightly scoped call or an explicit "expand" parameter — so that a broad search doesn't silently become a bulk PII export.
27 - Search should be **rate-limited and paginated** independent of retrieval, because unbounded search is the difference between "an analyst looked up one ticket" and "someone enumerated the table."
28 - Every search call is a natural audit-log event (who searched, for what, when) even before considering who's authorized to call it at all — this is a data-governance requirement (`../05-data-ops/CLAUDE.md`) that the API design needs to leave room for (e.g., not designing a stateless-only API that makes call attribution an afterthought).
29
30 **Why "plus security API authentication" is in the same assignment bullet as search/retrieve, not a separate question:** the assignment is signaling that the two are inseparable — a search/retrieve API over PII without an authentication design isn't a partial answer, it's a wrong one. This document asserts the product-level requirement (search needs scoped, attributable, rate-limited access, not just "logged in"); the actual authn/authz mechanism (API keys vs. OAuth2 client-credentials vs. mTLS, token formats, scope models) is `../02-ai-security-architecture/CLAUDE.md`'s to design.
31
32 ## 3. Third-party IDP connector: `/auth` and `/identity`
33
34 The assignment specifies exact contracts for both endpoints (see root `CLAUDE.md` — `/auth` takes `{"username", "password"}` and returns an `access_token`; `/identity` takes `{"phone", "name"}` and returns PII including a structured address). The design rationale here is about *why* that shape makes sense and *how to build the connector so it isn't hostage to any one provider's quirks* — not about re-specifying the contract, which is already fixed.
35
36 **Why the two-call shape (`/auth` then `/identity`) is the right pattern, per Persona 3** (the onboarding flow that pre-fills a new user's profile from a third-party IDP at signup; see `personas-use-cases.md`)**:** it's a minimal, legacy-style token-exchange flow — trade a long-lived credential (username/password) for a short-lived, purpose-scoped token, then use that token for the actual data pull. This is structurally the same shape as OAuth2's **resource-owner-password-credentials (ROPC) grant** — one of several standard ways an OAuth2 client obtains a token, this one taking a raw username/password directly instead of redirecting the user to log in elsewhere — and it is the specific grant type RFC 9700 now states MUST NOT be used, in favor of authorization-code or client-credentials flows (see `research-oauth-history.md` for the OAuth lineage this pattern sits in). Recognizing that lineage matters for the write-up: this connector contract is not a novel design, it's a well-understood, industry-standard-but-dated pattern (see `industry-framing.md` §2, revised after the S2 debate on `PLAN.md` — see `decisions/password-baseline-debate.md` — this is named as the conventional baseline, not as a claim about the assignment author's intent).
37
38 **Why the connector must be built provider-agnostic (ABC/XYZ as instances of one interface, not two bespoke integrations):** the assignment names the providers generically on purpose — this is a request to design *a connector interface*, with ABC and XYZ as its first two implementations, not to hardcode either one. Concretely, that means:
39
40 - A common internal interface — something like `Authenticate(username, password) → token` and `FetchIdentity(token, phone, name) → PII` — that both ABC and XYZ adapters satisfy, so adding a third provider is "write an adapter," not "touch the calling code."
41 - Each provider adapter owns its own quirks (token expiry format, field-name differences, rate limits, error codes) behind that common interface — the calling code (the onboarding flow from Persona 3, above) never branches on which provider it's talking to.
42 - The interface's shape should anticipate that a future provider might not be username/password-based at all — an OAuth2/OIDC provider would satisfy `Authenticate` differently (redirect-based auth-code exchange instead of a direct password POST) but still ultimately produce a token this connector can use the same way for `FetchIdentity`. This is the connector-side equivalent of `user_credential.method`'s extensibility from §1 — flagged here, not built here, per `industry-framing.md`'s point about not burying the actual assignment under speculative scope.
43
44 **Field-mapping note (product-level, not implementation):** the assignment's `/identity` response splits address into `street_address`, `locality`, `region`, `postal_code`, `country` — this is recognizably the OpenID Connect `address` claim shape (a widely-used standard structure for exactly this purpose), which is a useful fact for the write-up: it signals the assignment's own contract is already leaning on real industry convention rather than an arbitrary shape, which is worth naming explicitly as evidence of "this connector is meant to look like a real federated-identity integration," reinforcing the design choices above.
45
46 ## Summary for the engineering track
47
48 Three seams to build with deliberately, per this document: `user_credential.method` as a real discriminator (not a fixed enum), a search API designed around masked-by-default results + rate limiting + audit logging (not just an authenticated GET), and a provider-agnostic connector interface with ABC/XYZ as its first two adapters (not two copy-pasted integrations). Each seam is where this "before" baseline visibly points toward the "after" — passkeys, scoped search, and modern federated-identity providers — without this submission having to build any of the "after" itself.
49
50 ## Contract summary for 03
51
52 This section is the single-page version 03 should build against without re-reading the rest of this document. Where this section and the prose above conflict, this section is current.
53
54 **Entities (Q1)** — exact assignment field names:
55
56 - `user_profile`: `name`, `address`, `phone` (plus a primary key and the FK target for `user_credential`).
57 - `user_credential`: `username`, `method`, `password` (plus a primary key and a FK to `user_profile`).
58 - Relationship: **one `user_profile` to many `user_credential`** — do not model as 1:1. `method` is a real discriminator column (string/enum-with-room, not a fixed two-value enum) that determines which other columns on a `user_credential` row are meaningful; only the `"password"` method needs a password hash today. How that's physically modeled (nullable columns vs. method-specific side tables) is 03's and `../05-data-ops/CLAUDE.md`'s call, not fixed here.
59
60 **REST API (Q2):**
61
62 - `GET /profiles/{id}` — exact retrieval.
63 - `POST /profiles/search` — search, **not** `GET` with query params (rationale above). Request body shape: a top-level `expand: bool` field, sitting alongside the query fields (`name`, `phone`, partial-match flags), e.g. `{"name": "...", "phone": "...", "expand": false}` — not nested under a sub-object. Default (`expand` absent or `false`) returns masked/partial PII; `expand: true` requests full-field detail and, per the authz mechanism in `../02-ai-security-architecture/CLAUDE.md`, should require a more tightly-scoped credential than the base search call.
64 - Default response for search is masked/partial PII; `expand: true` is required for full-field detail and should be more tightly scoped than the base search call (same field, same meaning, as above).
65 - Search is rate-limited and paginated, independently of retrieval-by-ID.
66 - Every search call must be attributable and audit-logged (caller identity, query, timestamp) — the API shape must not make call attribution an afterthought (e.g., no anonymous/stateless-only search path).
67 - Authn/authz mechanism (API key vs. OAuth2 client-credentials vs. mTLS, token formats, scopes) is fully specified in `../02-ai-security-architecture/CLAUDE.md` — 03 implements against that document's design, not against an invention of its own.
68
69 **Connector interface (Q3):**
70
71 - Endpoints, methods, and JSON bodies for `/auth` and `/identity` are fixed by the assignment (see root `CLAUDE.md`) — 03 does not re-specify them.
72 - Build one provider-agnostic interface — `Authenticate(username, password) → token`, `FetchIdentity(token, phone, name) → PII` — with ABC and XYZ as its first two adapters. The calling code (onboarding flow) never branches on provider identity.
73 - `/identity` PII response fields (`name`, `phone`, `street_address`, `locality`, `region`, `postal_code`, `country`) match the assignment exactly; the address sub-shape matches the OIDC Core `address` claim (`research-oidc-address-claim.md`) — reuse those member names verbatim in Go structs, do not rename them.
74 - Leave the interface's `Authenticate` signature able to accommodate a future non-password-based provider (e.g., OAuth2/OIDC redirect flow) without redesigning `FetchIdentity` — flagged as a design constraint on the interface shape, not something to build now.
75
76 **Out of scope for 03 — do not re-derive, read the owning track instead:**
77
78 - The authn/authz *mechanism* for the search/retrieve API — `../02-ai-security-architecture/CLAUDE.md`.
79 - How `user_credential.method`'s discriminator is physically modeled (columns vs. side tables) and all schema/migration decisions — `../05-data-ops/CLAUDE.md`.
80 - The `/auth` and `/identity` field names and JSON shapes — fixed by the assignment text itself, not a design decision any track owns.
81 - Whether the framing narrative calls the password baseline "deliberate" — it does not, per the S2 structured written debate recorded in `decisions/password-baseline-debate.md` ("S2" = the second story in `PLAN.md`'s task list, not a spec term); 03 need not engage with that question at all, it only affects prose in this track's docs.
82
83 {
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/api-auth-design.md"
}1 # API Authentication & Authorization Design — Question 2
2
3 Owner: Marcus Ilori (`02-ai-security-architecture`). Covers the RESTful API service to search/retrieve `user_profile` data (assignment question 2, including its explicit "security API authentication" requirement). Implementation target: `../03-engineering-delivery/CLAUDE.md`. Consumes the schema from `../05-data-ops/CLAUDE.md`.
4
5 ## What "security API authentication" is scoped to mean here
6
7 The assignment names it without specifying a mechanism, so this is a design decision, not a given — stated with reasoning per this project's working principle.
8
9 Three realistic candidates and why I'm choosing among them:
10
11 | Option | Fit here | Verdict |
12 |---|---|---|
13 | Static API keys | Simple, but no expiry semantics, no scoping without inventing one, easy to leak into logs/URLs, no standard revocation flow | Rejected as the primary mechanism — acceptable only as a secondary, coarse-grained "which client system is this" identifier, never as sole authn/authz. |
14 | mTLS | Strong, but assumes both parties (this API and its callers) can manage client certs — reasonable for a closed set of internal service callers, awkward for anything resembling a broader consumer base | Recommended as a **transport-layer supplement** for service-to-service callers where the deploying org already runs a cert infrastructure; not the sole control. |
15 | OAuth2 client-credentials grant → short-lived JWT bearer token | Standard, expresses scopes natively, well-supported Go tooling, matches how the connector in question 3 already authenticates to us conceptually (token-based), and composes cleanly with per-caller authorization | **Chosen primary mechanism.** |
16
17 **Decision: OAuth2 client-credentials grant issuing short-lived JWT bearer access tokens, with mTLS as an optional additional transport control for known service-to-service callers.**
18
19 This is a machine-to-machine API (question 2 doesn't describe an end-user-facing login flow — it describes callers searching/retrieving profile data), so client-credentials is the correct OAuth2 grant type, not authorization-code (which is for delegated end-user consent) or password grant (deprecated, and inappropriate for a service caller).
20
21 ## Token shape and lifecycle
22
23 - **Format**: JWT, signed (not encrypted — it carries no secret, only claims), asymmetric signing (RS256/ES256) so the API can verify tokens without holding the signing key, which itself lives with a token-issuing authorization server component.
24 - **Claims, minimum set**: `sub` (calling client/service identity), `scope` (space-delimited authorization scopes, see below), `exp` (short — 5–15 minutes), `iat`, `aud` (this API specifically, to prevent a token minted for another service being replayed here).
25 - **No refresh tokens** for a client-credentials grant — the client re-authenticates with its own client secret when the access token expires. This bounds the blast radius of a leaked access token to its short TTL.
26 - **Revocation**: because JWTs are self-contained, true revocation requires either a short enough TTL that revocation is unnecessary in practice (the approach here) or a token-denylist check, which reintroduces a stateful lookup and defeats part of the point of JWTs. Given the 5–15 minute TTL, denylist is not required for this design; flagged here so a reviewer sees the trade-off was considered, not missed.
27 - **Algorithm confusion at the verifier — S8 review addition (Tomasz Wrede).** The verifier must pin the expected signing algorithm (RS256 or ES256, whichever this deployment chose) and reject any token whose header claims a different algorithm — specifically `alg: none` and an attacker-supplied downgrade to a symmetric algorithm (e.g., HS256 with the public key used as the HMAC secret, the well-documented RS256→HS256 confusion class). This is boilerplate for a correctly-configured JWT library, but it is exactly the kind of thing a security-graded design document should state explicitly rather than assume the library gets right by default — several real-world JWT libraries have shipped this vulnerability.
28 - **Bearer-token replay — named as an accepted risk explicitly, for consistency with the connector's token (S8 review, Tomasz Wrede).** This API's access tokens have the identical property S3 named for the connector's vendor token: bearer = possession-equals-authority, no proof-of-possession or session binding, for RFC 6750's stated reasons. **Accepted risk**, not a silent assumption: acceptable because the 5–15 minute TTL bounds the exposure window, and adding PoP (DPoP, RFC 9449) would be disproportionate complexity for a take-home's machine-to-machine API. Stated here so a reviewer doesn't have to infer why one token type in this project got an explicit accepted-risk line and the other didn't.
29
30 ## Authorization model: scopes, not just "authenticated"
31
32 The threat model (`threat-model.md`, Asset 2) already names the core risk: an authenticated-but-unscoped search/retrieve endpoint is a PII enumeration engine. Authentication answers "who is calling"; it does not answer "what may they see." This API needs both.
33
34 Finalized by the S4 adversarial pair (`decisions/search-authz-scoping.md`; Helena Marsh, Proposer; Ingrid Solano, Skeptic) — this section supersedes the earlier sketch.
35
36 ### Scope vocabulary
37
38 - **`profile:read:own`** — retrieve a single record by an id the caller already holds a legitimate reference to (e.g., a partner resolving a user it referred). Enforced against a `caller_referral` mapping (below), not by scope alone. Does not authorize search/query by name or phone fragment.
39 - **`profile:read:any`** — retrieve a single record by id, no referral constraint. Not a default grant: provisioning requires an entry in an explicit caller-role table (service name, business justification, and specifically *why* `read:own`'s referral constraint is insufficient for that caller's function), gated the same way `profile:search` is below. If no caller meets that bar, the scope stays defined but unissued.
40 - **`profile:search`** — query by name/phone fragment across the table; the PII-enumeration-engine risk the threat model names. Its own gated capability, not "`read:any` plus search."
41
42 ### `profile:search` provisioning bar (all four required)
43
44 1. Named service identity — never a shared client credential across a team.
45 2. Documented business function on file before provisioning, not granted speculatively.
46 3. Change-controlled approval with a named human approver, logged.
47 4. Tighter rate/result limits than any other scope (below).
48
49 ### Object-level policy hook
50
51 A per-scope policy function `authorize(sub, scope, target)` runs after the scope check, not instead of it — directly targeting OWASP API1:2023 (Broken Object Level Authorization):
52
53 - **`read:own`**: checks `target.id` against the `caller_referral` mapping. Scope-valid but outside the caller's referral set → reject, logged as a policy denial (distinct from an auth failure, for detection). `caller_referral` entries are data owned by `../05-data-ops/CLAUDE.md`, written only through the same partner-onboarding workflow as any other provisioning decision (named approver, logged) — no ad hoc inserts.
54 - **`read:any`**: requires a `reason_code` from a closed, versioned enum (`support_ticket`, `fraud_review`, `kyc_reverification`, `legal_hold`) logged with the audit record; adding a value to the enum requires a documented change. Freeform justification text is not acceptable — an unaudit-able reason code is the same gap as no reason code, six months later.
55 - **`profile:search`**: additionally enforces the per-query result cap; a query that would exceed it unconstrained (e.g., a bare wildcard) is rejected outright, not truncated.
56
57 ### Pagination, rate limits, and decomposition resistance
58
59 - **Cursor-based pagination**, not offset — offset walks turn a capped page size into full-table enumeration. Default page 20, hard ceiling 50.
60 - **Per-client-credential rate limits**: 60 req/min for `read:own`/`read:any`, 10 req/min for `profile:search`.
61 - **Cumulative distinct-record-touch counter per caller per rolling window** (e.g., no more than N distinct profiles returned to one credential per 24h), tracked independently of per-minute rate. A single-request cap and per-minute throttling only stop single-request or bursty enumeration; a caller staying under both limits can still walk the namespace in slices and reassemble the population over time (decomposition) — this counter is what catches that pattern regardless of how slowly it's spread. Counting-store mechanism is 03/04's to build; the requirement that volume is measured cumulatively, not just per-minute, is set here.
62 - **Applies to `profile:read:any` as well as `profile:search`, unconditionally on `reason_code` presence — S8 review fix (Tomasz Wrede).** The original wording ("without a fresh `reason_code`/justification") made the counter a no-op for `read:any`, because `read:any`'s object-level policy hook already requires a `reason_code` on *every* call — so every `read:any` request trivially satisfied the exemption, and a compromised-but-legitimate `read:any` credential could walk the entire table one id at a time, tagging every call with the same reason code, and never trip the counter. The counter now applies to `read:any` regardless of whether a `reason_code` is present; a valid, non-repeating reason code does not exempt a caller from the cumulative cap, only from the *per-call* policy check. This closes the gap for the scope with the broadest blast radius (no referral constraint), which nobody in S4's adversarial pair was looking at from this angle.
63 - Sustained near-limit usage on any of the above alerts, not just hard-blocks at the ceiling.
64
65 ### Grant lifecycle
66
67 - **90-day re-approval** on every scope grant (not `profile:search` alone) is a backstop, not the only mechanism. Grants are revoked immediately, ahead of the 90-day cycle, on: the named approver's offboarding, the caller's stated business function changing, or an idle trigger (no usage for 30 days → revoke pending re-justification) — whichever comes first. This requires 03/04 to consume an offboarding or ownership-change signal; the requirement (lifecycle-triggered revocation, not solely calendar-triggered) is asserted here, the plumbing is theirs.
68
69 ## Defense in depth beyond the token check
70
71 - **TLS 1.2+ only**, terminated in front of the API; no plaintext.
72 - **Input validation** on search parameters — reject unbounded wildcard queries, cap query complexity, parameterize all DB access (SQL injection is a data-ops/engineering implementation concern, but the API layer's job is to never pass a raw client string into a query string).
73 - **Audit logging**: every search/retrieve call logs caller identity (`sub`), scope used, record id(s) touched, and timestamp — never the PII field values themselves, per the threat model's log-hygiene requirement. This is what makes a compromised-but-legitimate credential's misuse detectable after the fact, which token-based authn alone does not give you.
74 - **Standard API hardening**: no PII in URLs (query strings get logged by proxies/CDNs outside our control) — record identifiers in the path are fine, but search terms containing a phone number or name fragment belong in a POST body or a properly-redacted query parameter set, not a GET query string that ends up in a shared access log.
75 - **Token-endpoint brute-force protection — S8 review addition (Tomasz Wrede).** Every rate limit above protects the resource API *after* a token is issued; none of them protect the token endpoint itself, where a caller presents `client_id`/`client_secret` to obtain one. Require: failed-grant rate limiting and progressive backoff per `client_id` and per source IP, and alerting on a sustained run of failed grants against one `client_id` — the same discipline `threat-model.md` Asset 1 applies to password brute-force, applied here to the client-credentials grant, which had no equivalent control stated. Client-secret entropy and storage are `04-infra-devops`'s and this document's own hashing requirement respectively; the token endpoint's own request-level defense is this document's gap to close.
76
77 ## Why not "just API keys"
78
79 Worth stating plainly since it's the simplest alternative a reviewer might expect: a static API key has no expiry, no standard scope representation, and if it leaks (URL, log, repo) it's valid until manually rotated. The chosen design bounds leak exposure to minutes (token TTL) rather than "until someone notices," and expresses authorization natively instead of requiring a bespoke key→permissions side table. That trade costs implementation complexity (an authorization-server component, however minimal) which is why it's stated explicitly here rather than assumed.
80
81 ## Objections considered (S4 adversarial pair)
82
83 Full record: `decisions/search-authz-scoping.md`. Ingrid Solano's five numbered objections against Helena Marsh's initial proposal, all accepted and revised into the sections above: (1) `read:any` was shaped before its actual callers were enumerated — fixed by requiring a caller-role table entry, gated like `search`; (2) `read:any`'s `reason_code` was freeform — fixed with a closed, versioned enum; (3) `caller_referral` membership had no owner or approval process, the softest control gating the narrowest scope — fixed by routing it through the same partner-onboarding workflow as any other grant; (4) per-request rejection and per-minute rate limits don't stop decomposition (slow enumeration under the cap) — fixed with a cumulative distinct-record-touch counter per rolling window; (5) 90-day re-approval is calendar-only, not lifecycle-tied — fixed with immediate revocation on offboarding, function change, or a 30-day idle trigger, with the 90-day cycle as backstop only.
84
85 ## S8 independent review (Tomasz Wrede)
86
87 Four findings, ranked; all accepted. **1 (high, fixed above):** the decomposition counter's exemption clause was unconditionally satisfied for `profile:read:any` because that scope already requires a `reason_code` on every call — a no-op control for the scope with the broadest blast radius. **2 (medium, fixed above):** no stated defense against JWT algorithm confusion at the verifier. **3 (medium-high, fixed above):** no brute-force/lockout protection on the client-credentials token endpoint itself, only on the resource API after a token issues. **4 (minor, fixed above):** this API's bearer-token replay risk was silently assumed safe rather than named as an accepted risk, unlike the connector's identical property in `connector-security.md`. Tomasz had no prior involvement with this document (his Phase 2 work was the connector, not the search API), so this review is independent per `PLAN.md` S8.
88
89 ## Sources
90
91 - RFC 6749 (OAuth 2.0), §4.4 Client Credentials Grant: https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
92 - RFC 7519 (JSON Web Token): https://datatracker.ietf.org/doc/html/rfc7519
93 - RFC 8725 (JSON Web Token Best Current Practices, §3.1 algorithm confusion / `alg: none`): https://datatracker.ietf.org/doc/html/rfc8725#section-3.1
94 - RFC 6750 §5 (Bearer Token Usage — threats and required practices, basis for the accepted-replay-risk line above): https://datatracker.ietf.org/doc/html/rfc6750#section-5
95 - NIST SP 800-63B §5.2.2 (rate-limiting/throttling online guessing attacks, applied here to the token endpoint): https://pages.nist.gov/800-63-3/sp800-63b.html
96 - OWASP API Security Top 10 2023, API1 (Broken Object Level Authorization) and API3 (Broken Object Property Level Authorization): https://owasp.org/API-Security/editions/2023/en/0x11-t10/
97 - NIST SP 800-63B (identity/authorization lifecycle, offboarding-triggered revocation) — cited in `decisions/search-authz-scoping.md`
98 {
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/personas-use-cases.md"
}1 # Personas & Use Cases 2 3 Prepared for the `01-product-industry-research-design` track. See `../CLAUDE.md` for the full assignment text and `./CLAUDE.md` for this track's scope. Ties directly back to the three assignment questions and to `industry-framing.md` in this directory. 4 5 **AI tooling note:** produced by Claude Code (Sonnet 5), track-owner subagent persona Naomi Voss, as a design-reasoning pass over the assignment text and this track's prior research — no new external research for this document. 6 7 "Who is this for" comes before "how do we build it" — so each persona below is answered before the corresponding assignment question is treated as a design problem. 8 9 ## Persona 1: The internal service developer (drives Q1 — the DAO) 10 11 **Who:** an engineer on a different internal team — say, an onboarding flow, a support tool, or a billing system — who needs to read or write a user's profile or credential record, but should never touch SQL or know which database backend is in play this quarter. 12 13 **Why they need this:** the assignment explicitly requires multi-database support (PostgreSQL/CockroachDB, SQLite, etc. — see root `CLAUDE.md`). That requirement only makes sense if there's a real reason the backing store might change: a startup prototyping on SQLite that needs to graduate to CockroachDB for multi-region durability without a data-layer rewrite, or a team standardizing on Postgres while a legacy service still runs SQLite. The DAO's job is to make that migration invisible to every consumer like this persona — they call `GetUserProfile(id)`, not `SELECT * FROM user_profile WHERE id = $1`. 14 15 **What this means for Q1's design:** the DAO's interface is the product, not the SQL underneath it. It should be judged on whether a consumer can be written against the interface alone, never learning which database is live. `user_credential.method` existing as a field (not assumed to always be `"password"`) is this persona's forward-compatibility guarantee — when passkeys get added, this developer's code that reads `user_credential.method` to branch on auth type doesn't need to change shape, only add a case. 16 17 ## Persona 2: The support/ops analyst using the search API (drives Q2 — the REST service) 18 19 **Who:** a customer-support agent or fraud/ops analyst who needs to look up a user profile by partial information — a phone number a customer just read aloud, a name with uncertain spelling, an account ID from a ticket — to resolve a support case or investigate a flagged transaction. Not a developer; a consumer of a UI/API surface someone else built on top of this service. 20 21 **Why they need "search," specifically, not just "retrieve":** retrieval-by-ID is the low-friction case; a support workflow rarely starts with a clean ID. It starts with whatever the customer said on the phone. That's why Q2 says "search **and** retrieve" — the API needs a query surface (name, phone, partial match) in addition to an exact-key lookup, and that query surface is exactly the thing that needs the tightest authz scoping, because "search by phone number" over a PII store is also the exact capability a bad actor wants. 22 23 **What this means for Q2's design:** the API's authentication isn't just "is this caller allowed to hit the service" — it's "what can this caller search for and see," because a support analyst's legitimate access pattern (look up one ticket's customer) looks identical, mechanically, to a scraping attack (enumerate the user table) unless the API is designed to distinguish them by identity, scope, and rate — the mechanics of which belong to `../02-ai-security-architecture/CLAUDE.md`, but the *reason* the distinction matters is this persona: a real support tool has real, narrow, audited search needs, not "return everything." 24 25 **Secondary persona for Q2 — the admin/back-office console.** Per this track's "does not own" boundary, this is light-touch: if a UI ever sits on top of the search API, it's an internal console for exactly Persona 2's workflow — a search box, a result list with masked PII by default (full PII visible only on deliberate expand + audit log entry), and no bulk-export button. That's a UX rationale note, not a wireframe deliverable for this backend-focused assignment. 26 27 ## Persona 3: The onboarding product owner needing third-party PII (drives Q3 — the IDP connector) 28 29 **Who:** a product owner building a signup/onboarding flow for a service that wants to pre-fill a new user's name, phone, and address rather than making them type it — because the user already has an account with a partner identity provider (the generic "ABC"/"XYZ" in the assignment), and re-typing the same PII into every new service is friction the industry has been trying to design away since long before OAuth existed (see `research-oauth-history.md` for that lineage). 30 31 **Why a service pulls PII from a third party rather than owning it:** two converging reasons, both real. First, **reduced liability and reduced collection surface** — a service that never independently collects and stores a user's address avoids being a second copy of that PII to secure and eventually breach; it becomes a consumer of an assertion instead of a second source of truth (this is the same reasoning behind "federated identity" broadly — don't collect what you can verify from someone who already has it). Second, **conversion/UX** — fewer form fields at signup is widely believed to reduce drop-off, which is consistent with why "sign in with X" patterns are common in consumer onboarding. 32 33 **What this means for Q3's design:** the `/auth` → `/identity` two-step (get a token, then use the token to fetch PII) is the connector pattern doing exactly what it should — a **short-lived, scoped credential** stands in for the user's actual third-party password, so the calling service never sees or stores that password, only a token it can use for the narrow purpose of one identity lookup. The design rationale for treating `ABC`/`XYZ` as pluggable providers behind a common connector interface (rather than two bespoke integrations) is in `api-connector-design-rationale.md` — but the product reason it must be pluggable is this persona: onboarding flows routinely need to support more than one partner IDP, and adding "sign in with a third provider" should be an integration, not a redesign. 34 35 ## Search authorization — product requirements 36 37 Product-level input to `../02-ai-security-architecture/CLAUDE.md`'s authz scoping decision on `profile:search` vs. `profile:read:own` — requirements only; no scope names, token formats, or policy mechanics, which are 02's to design. 38 39 **Persona 2 legitimately needs:** 40 41 - Search on `name` and `phone`, with partial-match support — a support analyst rarely has a clean, exact value for either. 42 - Masked/partial PII by default on every search result, with full-field detail requiring a distinct, more tightly scoped call. 43 - A deliberate "expand" action to see full PII on a specific result, with its own audit-log entry distinct from the search's own entry (search happened; then, separately, this specific record's full PII was viewed). 44 - Pagination on search results, independent of the masking/expand distinction. 45 - Every search call attributable to a specific caller identity — never an anonymous or shared-credential search path. 46 - Search and expand each carry an independent, time-bounded volume limit per caller identity, distinct from and in addition to per-call audit attribution — the product requirement is that no single caller identity can reconstruct a large slice of the PII store through many small, individually-compliant calls. (Attribution answers "who did this, after the fact"; volume limiting is the separate, live control that prevents the "after the fact" answer from being the only thing standing between this API and a slow bulk export. Mechanism and numbers are 02's.) 47 - A search response's visibility is not solely query-specificity-dependent: a query specific enough to match at most one record does not automatically earn additional unmasked fields — expand remains the only path to unmasked PII, regardless of how narrow the query was. 48 49 **Persona 2 must not be able to do:** 50 51 - Bulk export of search results — no "download all," no unbounded result size, no format designed for offline reuse of a result set. 52 - Search with no query constraint (a call that would return "everyone") — the API should require at least one real search field, not accept an empty or wildcard query. 53 - See full, unmasked PII from the search response itself — expand is always a separate, auditable action, never a response-shape option toggled for convenience. 54 - Search or expand without generating an audit-log entry — there is no "quiet" path through this API for a PII lookup. 55 56 ## Cross-cutting observation 57 58 All three personas share one property worth stating plainly: **none of them are the end user whose data this is.** The person whose name/phone/address sits in `user_profile`, and whose credential sits in `user_credential`, never directly calls any of these three interfaces — a developer, a support analyst, and a product owner do, on that person's behalf (with consent, in the legitimate cases). That's precisely why authz-by-caller-identity (not just authn) matters throughout, and why the third-party connector's job is explicitly framed as retrieving *someone else's* PII by proxy — the trust boundary is the whole point of the design, not an afterthought bolted onto a CRUD service. 59
{
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/decisions/error-semantics.md"
}1 # Decision: Error semantics (wrap vs. sentinel)
2
3 Pattern: rotating devil's advocate, decision 1 of 3 (seat: Oren Castellan). Proposer: Renata Cole.
4
5 ## Proposal
6
7 Go 1.13+ error wrapping (`fmt.Errorf("...: %w", err)`) for propagating unexpected/lower-level errors up the call stack, plus a small set of exported sentinel errors (`dao.ErrNotFound`, `dao.ErrDuplicate`) for conditions callers are expected to branch on via `errors.Is`.
8
9 ## Objections — Oren Castellan (Detail Hawk)
10
11 1. **Sentinel set must be closed and enumerated now**, not left open-ended — otherwise hires add ad hoc `ErrX` values across S2–S4 with no stable contract. Wants a fixed list plus a named owner and a propose-before-adding rule.
12 2. **Wrapped errors must never cross the HTTP boundary raw** — `err.Error()` on a wrapped DB error can leak driver text (DSN fragments, column/constraint names) to an API client. Wants an explicit translation function at the boundary; anything unmapped defaults to a generic 500 with no wrapped detail.
13 3. **`errors.Is` against a sentinel breaks silently if a backend returns a driver-native error instead** (e.g. `*pgconn.PgError` instead of `dao.ErrDuplicate`). Wants the `Repository` interface contract to state translation is *mandatory* per backend, verified by an S5 test case per backend.
14 4. **No stated rule for `context.DeadlineExceeded`/`context.Canceled`.** These aren't ordinary internal errors and shouldn't page as generic 500s. Wants them checked and mapped to a distinct response code before generic wrapping applies.
15
16 ## Ruling (Renata Cole)
17
18 All four accepted; no decline.
19
20 1. Sentinel set is closed and fixed now: `dao.ErrNotFound`, `dao.ErrDuplicate`, `dao.ErrInvalidInput`. Defined once in `internal/dao`. Rule: no hire adds a sentinel unilaterally — propose it to me with the one-line reason, I add it. This applies for the rest of the design/implementation phase.
21 2. `internal/api` never serializes `err.Error()` on an unmapped error. A translation function (`internal/api` boundary layer) maps the three sentinels to their HTTP status/safe message; anything else defaults to a generic 500 with no wrapped detail surfaced to the client. Wrapped errors are for logs only.
22 3. The `Repository` interface contract states translation of known driver-native errors to the shared sentinels is **mandatory**, not optional, per backend implementation. S5's test strategy gets an explicit line item: one test per backend proving the translation happens (e.g., a unique-constraint violation surfaces as `dao.ErrDuplicate`, not a raw driver type).
23 4. `context.DeadlineExceeded` and `context.Canceled` are checked explicitly and mapped to a distinct response (504 for deadline exceeded; treat client-cancel as a non-alerting outcome, not logged as a 500) before generic wrapping/translation applies.
24
25 ## Status
26
27 Ruled. Carries into S2 (DAO sentinel definitions + per-backend translation) and S3 (handler-layer translation function, context-error handling).
28
29 ## Superseded in part — 05's contract lands (2026-09-12)
30
31 05's `multi-db-strategy.md` §4 is the authoritative sentinel set and translation mechanism for the DAO layer; it supersedes the specific names and the translation rule this decision guessed at before the contract existed. What stands vs. what's replaced:
32
33 - **Replaced:** the invented sentinel set (`ErrNotFound`/`ErrDuplicate`/`ErrInvalidInput`) is superseded by 05's actual seven: `ErrNotFound`, `ErrAlreadyExists`, `ErrDuplicateUsername`, `ErrInvalidMethod`, `ErrInvalidQuery`, `ErrInvalidArgument`, `ErrInvalidCredential`. These are 05's to own and extend (own-and-propose-before-adding now routes through them, not me, for anything DAO-internal) — my closed-set/no-ad-hoc-additions principle still applies, just to their list.
34 - **Replaced:** "match SQLSTATE only" is superseded by 05's corrected rule — match structured error codes (SQLSTATE on Postgres/CockroachDB, extended result codes on SQLite) plus constraint names we define ourselves in DDL, never free-text messages. This is a more complete version of objection 2's concern (leaking driver text), not a contradiction of it.
35 - **Stands unchanged:** the HTTP-boundary rule (objection 2 — `internal/api` never serializes `err.Error()` on an unmapped error; wrapped errors are for logs only) — this is 03's layer, not 05's, and 05's contract doesn't touch it. The context-cancellation rule (objection 4) also stands unchanged, same reason.
36 - **Stands, now backed by a real mechanism instead of a guess:** objection 3's demand for mandatory per-backend translation — 05's contract §4 gives the actual mapping table (condition → PG/CRDB code → SQLite code → sentinel), and §7's conformance suite makes "sentinel parity for every error condition in §4" a stated, required test — stronger than what this decision asked for.
37
38 ---
39 Model: sonnet (Oren Castellan, objection seat; Renata Cole, lead, proposer + ruling). Turns consumed: 1 hire turn + lead synthesis.
40 {
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/PLAN.md"
}1 # Track 03 (Engineering & Delivery) — Phase 1 Plan 2 3 ## Context 4 5 Team-lead has asked every track lead to plan in plan mode before any hiring or code. This 6 track (Renata Cole) owns the actual Go implementation of all three assignment questions — 7 the DAO, the REST API, and the IDP connector — so its plan needs to name concrete, 8 Jira-shaped deliverables, propose a 4-hire roster with real personality variance (per 9 `CASTING.md`), decide which decisions in this track deserve a debate pattern versus which are 10 waste, state the dependency order this track already committed to in 11 `../02-ai-security-architecture/planning-approach.md`, and produce the "Service boundaries" 12 section of root `PLANNING.md` that 04 (Theo) is blocked on reading. Nothing here is new 13 strategy — it operationalizes what's already agreed in `planning-approach.md` and the root 14 `CLAUDE.md`'s org section. No code is written in this phase. 15 16 ## 1. Deliverables & tasks (one Jira Story each; Epic = this track) 17 18 | Story | What | Hand-off consumed | Hand-off produced | Acceptance standard | 19 |---|---|---|---|---| 20 | S1. Scaffolding | Go module layout, `cmd/` entrypoints, route wiring, stub handlers, config loading | none (assignment text only) | `PLANNING.md` Service boundaries (this plan, §4 below) | Buildable skeleton; 04 can read the boundaries doc without asking a follow-up question | 21 | S2. DAO per backend | `internal/dao` `Repository` interface + Postgres/CockroachDB/SQLite implementations for `user_profile`, `user_credential` | 05's Go-shaped contract in `../05-data-ops/multi-db-strategy.md` (interface signatures, field types/nullability, credential-method-as-lookup-table note) | none outbound | 03 implements directly from the contract with no re-derivation of schema or interface decisions; if the contract is prose instead of Go signatures, this story is blocked and kicked back to 05 | 22 | S3. REST handlers + auth middleware | Search/retrieve `user_profile` endpoints (Q2) + middleware enforcing 02's scheme | 02's receiver-shaped auth hand-off (token scheme, validation point — the deferred follow-up to `api-auth-design.md` noted in 02's own `CLAUDE.md` Status) | none outbound | Middleware implements the scheme as specified; no alternatives re-litigated in code | 23 | S4. Connector `/auth` + `/identity` | Go client + handlers per the assignment's fixed JSON contracts | 02's `connector-security.md` (token lifecycle: TTL, storage, refresh, never-persist-in-plaintext) | none outbound | Token-lifecycle logic matches 02's spec exactly; endpoint paths/bodies are the assignment's, not re-designed | 24 | S5. Tests | Table-driven unit tests for DAO (mocked driver), handlers (mocked DAO), connector (mocked HTTP) | S1–S4 outputs | none | Stated strategy: unit-heavy, integration deliberately stubbed (assignment doesn't require full end-to-end); documented, not silently absent | 25 | S6. README | What's implemented vs. stubbed/mocked and why; AI tool/workflow used (this plan's own hiring + patterns); test-strategy section must state the hand-written-fake rule from `decisions/test-double-strategy.md` (same-PR fake update on interface change) since that's where LoginID reads the test approach | all of the above | the track's README | A cold reader can tell what runs vs. what's a stub without asking | 26 27 ## 2. Hire roster — 4 hires (suggested cast from `CASTING.md`, adopted as-is) 28 29 All four: **model sonnet** (planning/design/implementation tier per `planning-approach.md`; 30 opus is reserved for the connector token-lifecycle *implementation* seat in a later phase, not 31 planning — none of these four need it now). No hire spawns subagents; no CLAUDE.md marker; 32 profile-gen text-first, after "hiring go". 33 34 **Oren Castellan — Detail Hawk, code reviewer.** *Function:* second-pass review on DAO/handler/connector Go for error semantics, nullability, off-by-one — the review-seat Renata asked for on her own output, plus the independent pass 02 called for on security-graded surfaces. *Tags:* serious, cautious. *Pattern/role:* rotating devil's advocate seat (implementation calls); second-pass reviewer on S3/S4. 35 36 *Persona:* Meticulous and understated; in review he goes quiet, then produces a numbered list nobody else caught. Decides by tracing every input to its type and zero value before trusting a code path. Pushes back on "the happy path handles it" and on errors swallowed with `_`. Eight years reviewing Go services at a logistics company, two more doing contract review for a fintech's PCI-scoped repos. Deep in `go vet`/`staticcheck`, table-driven tests, and nil-vs-empty-slice distinctions. Blind spot: he can spend an hour on a rare nullability edge case while missing that the function solves the wrong problem — pair him with someone who checks the solution's shape first. In disagreement he stays procedural, names the specific line and failure, and drops it once satisfied. (129 words) 37 38 **Nolan Reyes — Veteran, ops/migrations/token-lifecycle sanity.** *Function:* pattern-matches implementation and layout decisions against real production failure modes; pessimist hat in the Go-layout three-hats run. *Tags:* serious, cautious. *Pattern/role:* three-hats pessimist (Go layout); rotating devil's advocate participant. 39 40 *Persona:* Dry and unhurried; opens reviews with what broke last time he saw this pattern in production. Decides from incident history more than documentation. Pushes back on any "this will just work" claim about retries, timeouts, or token refresh under load. Eleven years in platform ops — six at a regional bank's card-issuing platform, five running SRE for a logistics SaaS — paged once for an expired-token cascade and once for a migration that locked a table for forty minutes; never forgot either. Deep in Go's `context` package, connection pooling, and graceful shutdown. Blind spot: he over-indexes on failures he personally lived through and under-weights new ones. Argues by scenario, not principle, and concedes fast when shown a failure mode he hasn't seen. (134 words) 41 42 **Marisol Ferran — Enthusiast, Renata's temperament opposite (required).** *Function:* pushes for trying the promising-but-unproven tool (codegen, generics) rather than the safe default; optimist hat in three-hats. *Tags:* playful, bold. *Pattern/role:* three-hats optimist (Go layout); counterweight voice on S2's driver-abstraction choice. 43 44 *Persona:* Bright and quick to say "let's just try it" before the room finishes weighing options; energized by tools that remove boilerplate. Decides by prototyping fast and showing the result rather than arguing it abstractly. Pushes back on "we've always done it this way" and on process that delays the first line of code by a week. Four years building internal tools at a logistics startup, two more at a small dev-tools shop evaluating codegen and ORM options for clients. Comfortable with `sqlc`, Go generics, and OpenAPI-driven scaffolding. Blind spot: she underweights the years-long operational cost of a clever tool and can talk a team into a dependency nobody wants to own. In disagreement she stays upbeat, offers to build the smaller version on the spot, and yields once the maintenance cost is spelled out. (141 words) 45 46 **Ines Dabrowski — Systems Cartographer, Go layout / service topology.** *Function:* owns the diagramming behind the Service boundaries decision below; draws the package/dependency map before anyone commits to prose. *Tags:* serious, bold. *Pattern/role:* drafts the Service boundaries doc (§4); non-hat contributor to three-hats as tie-break if optimist/pessimist stall. 47 48 *Persona:* Calm and visual; sketches a box-and-arrow diagram before committing an opinion to words. Decides by mapping every boundary and dependency first, then asking what crosses it and why. Pushes back on two services sharing a package for convenience rather than a real shared concern. Seven years as a platform engineer at a multi-tenant SaaS company, three designing service boundaries for a payments integration team. Strong on Go module layout, dependency direction, and where an interface should live versus where it's implemented. Blind spot: she can over-abstract a boundary that would have been fine as one package at this project's size, mistaking cleanliness for necessity. In disagreement she draws the alternative rather than argues it, and updates the diagram in front of the room when persuaded. (135 words) 49 50 Roster spans both required axes (serious/playful via Marisol; cautious/bold via Ines and 51 Marisol), includes Renata's required opposite (Marisol), and duplicates no archetype from 52 another track's *function* (Detail Hawk here is a code reviewer; 05's Detail Hawk is a DDL 53 reviewer, a different function — allowed per variance rule 2). 54 55 ## 3. Debate plan 56 57 - **Three hats — Go layout (one binary vs. two).** Marisol (optimist: ship one binary, less 58 ceremony) / Callum (pessimist: two binaries, because a shared-everything binary is how a 59 card-issuing platform he worked on ended up with the connector's outbound vendor calls 60 taking down the client-facing API during an incident) / **Renata takes the pragmatist hat 61 herself** rather than hiring a fifth (catalog explicitly allows this) — she's the 62 Minimalist-leaning lead per `CASTING.md` §7. Ines drafts the resulting diagram either way. 63 Decision recorded in `decisions/go-layout-debate.md`. 64 - **Rotating devil's advocate — implementation calls.** Error semantics (wrap vs. sentinel 65 errors), context propagation into the DAO layer, test-double strategy (interface fakes vs. 66 generated mocks). Seat rotates Oren → Callum → Marisol → Ines per decision so no one hire 67 becomes "the objector." Cheapest pattern; run on every non-trivial implementation call in 68 S2–S4. 69 - **Newcomer's-question pass on hand-offs — run by Renata herself, not a hired Newcomer.** 70 This track wasn't allocated a Newcomer seat (04 has one; hiring a second here would exceed 71 the intent of a 4-hire cap for marginal gain). Renata reads 05's `multi-db-strategy.md` and 72 02's receiver-shaped hand-offs cold, the moment they land, and lists anything she can't act 73 on without re-deriving the sender's reasoning — same test the pattern exists to run, just 74 performed by the actual receiver instead of a proxy. 75 - **Explicit waste:** the `/auth` and `/identity` endpoint paths and JSON bodies are fixed by 76 the assignment text — never debated. Red team/blue team is 02's pattern for the token 77 lifecycle's security properties, not this track's to re-run; 03 only debates *how* it 78 implements what 02 specifies (error handling, retries), never *whether* the scheme is right. 79 80 ## 4. Dependencies (and the Service boundaries output for 04) 81 82 **Split start**, consistent with `planning-approach.md` §2: S1 (scaffolding) starts as soon as 83 hiring completes — no upstream blocker. S2 (DAO) is blocked on 05's Go-shaped contract landing 84 *stable* (not polished — unlikely to be rewritten). S3's middleware is blocked on 02's 85 receiver-shaped auth hand-off; S3's route wiring and non-auth handler logic can start earlier. 86 S4 is blocked on 02's `connector-security.md` token-lifecycle spec, which already exists in 87 prose form — implementable now, refined if 02's deferred receiver-shaped version changes 88 anything. 89 90 **Service boundaries (written into root `PLANNING.md` alongside this plan):** 91 92 - **Two binaries**, not one: `cmd/api-service` (Q1 DAO + Q2 REST API, client-facing, bearer- 93 token auth) and `cmd/idp-connector` (Q3, outbound-only to vendors). Reasoning: different 94 threat models and auth surfaces per 02's track, independent scaling/deploy needs, and it 95 mirrors the assignment's own Q2/Q3 split rather than inventing a new boundary. 96 - **Package layout:** 97 ``` 98 cmd/api-service/main.go 99 cmd/idp-connector/main.go 100 internal/dao/ # Repository interface + postgres/cockroachdb/sqlite implementations 101 internal/model/ # shared structs: UserProfile, UserCredential 102 internal/api/ # REST handlers, router, auth middleware 103 internal/connector/ # /auth, /identity client + handlers 104 internal/config/ # env-var config loading, shared by both binaries 105 ``` 106 - **Config surface:** environment variables only (12-factor, container-friendly for 04) — 107 `DB_DRIVER` (postgres|cockroachdb|sqlite), `DB_DSN`, `HTTP_ADDR`, `AUTH_JWT_ISSUER` / 108 `AUTH_JWT_AUDIENCE` (names pending 02's final scheme), `IDP_ABC_BASE_URL`, 109 `IDP_ABC_CLIENT_ID` / `IDP_ABC_CLIENT_SECRET`. No flags, no config files. 110 - **DAO driver-selection mechanism:** a factory `dao.New(driver, dsn string) (dao.Repository, error)` 111 chosen at startup from `DB_DRIVER`; every backend satisfies one `Repository` interface 112 (exact method signatures finalized once 05's contract lands — the factory *shape* is stable 113 now, its return type's method set is not). 114 - **What 04 may treat as stable:** the two-binary split, the `cmd/` entrypoint names, the 115 env-var config approach and the variable names above, and the `dao.New(driver, dsn)` factory 116 signature. **Not yet stable:** internal DAO implementation details, ORM/query-builder choice, 117 and handler-package internals — those can still shift as 05's and 02's hand-offs land. 118 119 ## Verification 120 121 - `PLAN.md` for this track matches this file verbatim once copied. 122 - `PLANNING.md` row 03 updated to "plan drafted — awaiting hiring go"; Service boundaries 123 section replaces its "TBD" with the content in §4 above. 124 - No `.go`, `.sql`, `go.mod`, or agent-definition files exist yet — only prose changed. 125 - Roster (4 hires) checked against `CASTING.md` variance rules before messaging team-lead: 126 no archetype repeated within this team; Renata's required opposite (Enthusiast) present; 127 two temperament axes spanned; no first name collides with any lead. 128
{
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/decisions/connector-token-lifecycle-redblue.md"
}1 # Decision: Connector Token Lifecycle — Red/Blue Run (S3)
2
3 Pattern: red team / blue team, amended per `CASTING.md` §5 ⟨02⟩. Surface: the IDP connector's handling of the vendor `access_token` (`connector-security.md` §1), against the assignment's `/auth` → `/identity` contract. Table shape: `planning-approach.md` Appendix A. This record is the full positions and rebuttals; the completed table is appended to `connector-security.md`.
4
5 ## Seats
6
7 - **Red — Tomasz Wrede.** Ranked top five attack leaves against the design as it stood before this run.
8 - **Alternatives — Felix Adebayo.** One turn between red and blue: design changes that delete a leaf outright.
9 - **Blue — Helena Marsh.** Answers every leaf with a control or an explicitly accepted risk.
10 - **Rebuttal — Tomasz Wrede.** One turn on the top three, after blue's answer.
11 - **Lead — Marcus Ilori.** Residual scoring and ruling.
12
13 ## Red: Tomasz's ranked top five
14
15 1. **Cache-key confusion → cross-tenant token/PII leak.** The pre-existing design said the cache is "keyed so one vendor-account's token is never returned to a caller resolving a different vendor account" without stating the key derives from an immutable vendor-account id rather than caller-supplied `name`/`phone`. If the key derives from request fields, an attacker who knows or guesses another user's name+phone collides with their cache entry.
16 2. **Fail-open on KMS unavailability.** §1 mandated encryption at rest via KMS but never stated the failure mode if KMS was down or slow during a caching window — a plaintext-cache fallback "to preserve availability" would be an instant compromise path.
17 3. **Vendor token replay, no binding to caller/session.** The cached token, once decrypted for use, had no stated binding (no DPoP-style proof-of-possession, no IP/session pinning); anyone reading it during its TTL could replay it against the vendor for the remaining TTL.
18 4. **Process-memory/crash-dump exposure.** The plaintext token transits application memory before any encryption happens; no stated memory-scrubbing or core-dump suppression.
19 5. **Connector as a credential-stuffing oracle against the vendor.** Rate-limiting was stated ("independent of the vendor's own limits") without stated granularity — per-caller, per-IP, or global.
20
21 Noted but not in the top five (Tomasz's own list, kept for the record, not scored in this run): long vendor-token TTLs treated as inherently acceptable without a stated ceiling; a concurrent-refresh race causing a thundering herd against vendor `/auth`; scope-narrowing silently dropped for vendors that don't support scoping.
22
23 ## Alternatives: Felix's turn
24
25 Applied "what if the connector held no token at all?" against the specific five, not in the abstract:
26
27 - **Leaves 1 and 2 deleted by the same design change:** make caching forbidden by default rather than merely optional. Fetch a fresh vendor token per `/identity` call, hold it only inside that request's execution scope, discard on return. No cache → no cache key → leaf 1 has no substrate to exist in. No cache → no KMS-encrypted-at-rest dependency in the hot path → leaf 2's failure window doesn't exist.
28 - **Leaf 4 shrunk, not deleted:** a token living only inside one request's stack for one outbound call is a much smaller target than one sitting in an encrypted cache with a multi-minute TTL, but it still exists in memory while in use — that's physics, not a design flaw to wish away.
29 - **Leaves 3 and 5 not eliminated, stated plainly rather than stretched for:** replay resistance requires vendor-side PoP support the assignment's generic ABC/XYZ contract doesn't give us — "hold no token" narrows the exposure window but doesn't change the vendor's own bearer-token semantics. Leaf 5 is a rate-limiting problem on our own `/auth` pass-through, not a token-custody problem — no change to how we hold the token touches how often a caller can hit us.
30 - **Net proposal:** replace "TTL-bounded encrypted cache, optional" with "no persistent or cached token; fetched fresh and scoped to the single `/identity` request" as the default, with caching as an explicitly-justified exception requiring its own re-review. Flagged honestly: does nothing for replay or rate-limiting, and may add a vendor round-trip per call — Helena's tradeoff to weigh against latency.
31
32 ## Blue: Helena's ruling and answers
33
34 **On Felix's proposal:** accepted. Not a reversal of §1 — a tightening of language that already made caching conditional into a hard default: caching is forbidden unless an explicit exception is opened, with its own re-review. Controls are written for the exception path only, since the default posture has no substrate for leaves 1 and 2 to attack.
35
36 | # | Leaf | Blue response | Owner | Source |
37 |---|---|---|---|---|
38 | 1 | Cache-key confusion | Control, exception-path only: any future caching exception must derive the key from an immutable, vendor-issued identifier (our tenant id + vendor id + the vendor's own opaque subject id) — never from request-supplied `name`/`phone`. Same object-level-authorization discipline as scope checking on the search API; a cache key is an authorization boundary. | 03, if exception ever opened | OWASP API1:2023 (BOLA) |
39 | 2 | KMS fail-open | Control, exception-path only: if the envelope key is unreachable, cache write/read fails closed to "no cache" for that call — a live vendor fetch, not an unencrypted write and not a hard connector failure. | 03/04 (KMS mechanism is 04's) | NIST SP 800-57 |
40 | 3 | Replay, no PoP | Accepted risk. Bearer tokens under RFC 6750 are possession-equals-authorization by design; PoP/binding needs vendor-side support not assumable for a generic vendor. Acceptable for this system because fetch-per-call bounds the window to one request's execution scope. Re-review trigger: a PoP-capable vendor surfacing in 01's research. | — | RFC 6750 §5; RFC 9700 §2.5 |
41 | 4 | Memory/crash-dump exposure | Control: exclude the token field from any generic request/response dump used in error handling (panics, debug dumps — same discipline as logs); scope the variable to the narrowest function, no passing beyond the vendor-call function. Residual accepted: Go gives no guaranteed memory zeroing without unsafe/cgo, so a crash dump mid-call could still capture it — acceptable given the window is one function call, not a cache TTL. Core-dump suppression is a 04 mechanism, requirement recorded here. | 03 (code discipline) / 04 (runtime config) | OWASP Logging Cheat Sheet |
42 | 5 | Credential-stuffing oracle | Control: rate limit keyed per calling-API-client and per source IP, with a stricter threshold on vendor-auth failure ratio specifically (credential stuffing shows as high failure ratio before high volume), paired with exponential backoff per identity attempted. Closes the granularity gap §4 already gestured at rather than adding a new requirement. | 03 (rate-limit logic) / 04 (shared store/edge mechanism, if gateway-enforced) | OWASP API4:2023; NIST SP 800-63B §5.2.2 |
43
44 ## Rebuttal: Tomasz's turn on the top three
45
46 - **Leaf 1 — conceded, conditional.** The persistent-cache variant is dead: no cache, no key-collision cache hit. But "no cache by default" only kills the storage leaf, not a connection-reuse variant Helena and Felix didn't address: a pooled/keep-alive HTTP client that doesn't reset the `Authorization` header per call can attach request A's token to request B's outbound call — same cross-tenant leak, zero cache required. Asked for an explicit requirement that the outbound client construct a fresh, isolated auth header per request, not per pooled connection.
47 - **Leaf 2 — conceded outright.** No cache removes the write-time KMS dependency; the exception-path fail-closed control is exactly right. No hole found.
48 - **Leaf 3 — held, with a boundary condition, not reopening the ruling.** Agrees bearer + no PoP for a generic vendor is a real constraint, not a design failure, and fetch-per-call shrinks the window. But "one request's execution scope" needed the boundary stated explicitly: fetch → use in the immediately following call → zeroize, and specifically whether a retried `/identity` call (§4 permits retry/backoff) reuses the same token or forces a fresh `/auth` — if it reuses, the accepted window is however long the retry policy runs, not "one call."
49
50 ## Ruling (Marcus Ilori)
51
52 Both of Tomasz's rebuttal points are accepted as tightenings, not reopenings of Helena's ruling, and are folded into `connector-security.md` §1 directly:
53
54 1. **Leaf 1, connection-reuse variant — closed.** Added as a required implementation discipline: the outbound HTTP client to the vendor must construct a fresh, isolated `Authorization` header per request, never inherited from connection reuse.
55 2. **Leaf 3, boundary — tightened.** Added explicit fetch-use-zeroize language: the token's scope runs from the `/auth` response to the immediately following `/identity` call and no further; a retried `/identity` call re-fetches via `/auth` and never reuses a token from a prior attempt.
56
57 Residual risk: leaves 1, 2, 4, 5 score **L** after blue's response and this run's tightening — each has either had its substrate eliminated by design or a checkable control that closes the gap Tomasz named. Leaf 3 scores **M**: accepted, not eliminated, because it is a genuine vendor-contract limitation this project has no lever to remove, not a design gap left unaddressed. No leaf scores **H**; no follow-up Story is required. Leaf 3's **M** is named here as the one residual risk Warren should see plainly in the artifact, per the amendment's own rule.
58
59 ---
60 Model: sonnet (Tomasz Wrede, red + rebuttal; Felix Adebayo, alternatives; Helena Marsh, blue) / sonnet (Marcus Ilori, lead, ruling and residual scoring). Turns consumed: 5 (red top-five, alternatives, blue, rebuttal, lead ruling) — matches the 4-turns-plus-lead budget in `PLAN.md` §3.
61 {
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/decisions/go-layout-debate.md"
}1 # Decision: Go layout — one binary vs. two 2 3 Pattern: three hats (Marisol Ferran, optimist · Nolan Reyes, pessimist · Renata Cole, pragmatist). Diagram: Ines Dabrowski (pending, will be appended when delivered). 4 5 ## Optimist (Marisol Ferran) 6 7 Argued for one binary on take-home-appropriate grounds: every extra binary is authoring/wiring/README ceremony that doesn't teach the reader anything, for a submission graded on demonstrated reasoning. Conceded the isolation argument is real and already published as stable in `PLANNING.md` with 04 building against it, so declined to relitigate the boundary itself. Actual proposal: **keep two binaries, merge the ceremony, not the process boundary** — both `cmd/api-service/main.go` and `cmd/idp-connector/main.go` become thin wrappers around one shared `internal/app` package (route registration, config loading), so the deployables stay two but the authored code stays close to one. Would yield even that if a shared-bootstrap bug risked taking both down at once. 8 9 ## Pessimist (Nolan Reyes) 10 11 Pattern-matched to a production incident: a shared process/connection pool let a slowly-timing-out downstream partner exhaust the pool and take down an unrelated inbound API — 40 minutes of a partner's problem becoming the platform's outage. Confirmed two binaries closes the *shared-fate* version of that failure (a stalled vendor can't starve `api-service`'s own pool). Flagged what it does **not** close: host/deploy colocation (same node, same FD/CPU budget, same on-call page) is a 04 question, not implied by the binary split; and a self-inflicted retry storm in the connector's token-refresh logic (no backoff) is a failure mode independent of process boundaries entirely. Explicitly not arguing against two binaries — arguing that the boundary alone doesn't buy safety without `context.WithTimeout` on every outbound call and a bounded worker pool. 12 13 ## Ruling (Renata Cole, pragmatist) 14 15 **Two binaries stands** — this was already published in `PLANNING.md`'s Service boundaries section as stable, 04 is building against it, and neither hat argued for reopening it. Confirmed rather than relitigated. 16 17 **Adopting Marisol's shared-`internal/app` proposal.** Both `cmd/*/main.go` become thin wrappers over `internal/app`: route registration and config loading live once, not twice. This gets the authoring-cost win she argued for without touching the process boundary 04 depends on. Package layout in `PLANNING.md` gets a note added (not a structural change — `internal/app` slots alongside the existing `internal/` packages). 18 19 **Adopting Nolan's requirement, not just noting it.** Carrying into S4's spec (connector `/auth` + `/identity`): every outbound HTTP call to a vendor must set a `context.WithTimeout` (no unbounded outbound calls), and the connector's HTTP client uses a bounded connection pool. Token-refresh/retry logic must back off, never tight-loop against a failing vendor endpoint. This is now a stated acceptance criterion for S4, not an assumption riding on "it's a separate binary." 20 21 **Flagging to 04, not deciding here (Nolan's other point):** whether `api-service` and `idp-connector` are deployed to the same host/node is 04's call, and the isolation this decision buys is *process*-level, not necessarily *host*-level, unless 04 also separates deployment. Will note this explicitly when the Service boundaries section is next touched, so 04 doesn't read "two binaries" as "two hosts" by default. 22 23 ## Diagram (Ines Dabrowski) 24 25 ``` 26 ================================================================================ 27 GO LAYOUT — TWO BINARIES, DEPENDENCY DIRECTION 28 ================================================================================ 29 30 External callers 31 │ 32 │ HTTPS + bearer token (JWT, iss=https://auth.loginid-takehome.internal, 33 │ aud=loginid-api-service) 34 ▼ 35 ┌──────────────────────────────┐ ┌──────────────────────────────┐ 36 │ cmd/api-service │ │ cmd/idp-connector │ 37 │ (client-facing, inbound) │ │ (outbound-only to vendors) │ 38 │ │ │ │ 39 │ main.go │ │ main.go │ 40 │ │ │ │ │ │ 41 │ ▼ │ │ ▼ │ 42 │ internal/api │ │ internal/connector │ 43 │ - router │ │ - POST /auth (client) │ 44 │ - auth middleware ─────────┼──┐ │ - POST /identity (client) │ 45 │ - handlers (search/get │ │ │ - token lifecycle │ 46 │ user_profile) │ │ │ (TTL/refresh/no- │ 47 │ │ │ │ │ plaintext-persist, │ 48 │ ▼ │ │ │ per 02 connector- │ 49 │ internal/dao │ │ │ security.md) │ 50 │ - Repository (composite): │ │ │ │ │ 51 │ Profiles() │ │ └────┼─────────────────────────┘ 52 │ Credentials() │ vendor XYZ/ABC (POST /auth, │ 53 │ Methods() │ /identity — assignment-fixed) │ 54 │ - postgres / cockroachdb / │ 55 │ sqlite implementations │ 56 │ │ │ 57 │ ▼ │ 58 │ internal/model │ ◄── shared by BOTH binaries 59 │ UserProfile, UserCredential │ (no behavior, structs only) 60 └──────────────┬────────────────┘ 61 │ 62 ▼ 63 internal/config ◄── shared by BOTH binaries 64 env-var loading only: DB_DRIVER, DB_DSN, DB_DSN_FILE, 65 HTTP_ADDR, AUTH_JWT_ISSUER, AUTH_JWT_AUDIENCE, 66 IDP_ABC_BASE_URL, IDP_ABC_CLIENT_ID/SECRET 67 68 -------------------------------------------------------------------------------- 69 DEPENDENCY DIRECTION (arrows = "depends on", never reversed) 70 -------------------------------------------------------------------------------- 71 cmd/api-service → internal/api → internal/dao → internal/model 72 cmd/idp-connector → internal/connector → internal/model 73 both cmd/* → internal/config 74 75 internal/model and internal/config are the ONLY packages both binaries import. 76 Neither is a dumping ground: model = data shape only, config = env parsing only. 77 internal/api and internal/connector never import each other. 78 internal/dao is imported ONLY by cmd/api-service — idp-connector has no DB access, 79 by design: it never touches user_profile/user_credential storage, only vendor PII 80 in flight. 81 82 -------------------------------------------------------------------------------- 83 WHY TWO BINARIES, NOT ONE (the boundary this diagram backs) 84 -------------------------------------------------------------------------------- 85 - Different threat models: api-service holds the DB and validates inbound bearer 86 tokens; idp-connector holds vendor credentials and calls out. A shared process 87 means a vendor-call bug (slow vendor, panic, goroutine leak) can degrade the 88 client-facing API — the exact incident shape Nolan's pessimist hat is naming. 89 - Independent scaling: connector traffic is bursty/vendor-latency-bound; api-service 90 traffic is client-request-bound. One binary couples their resource profiles for 91 no shared concern. 92 - Mirrors the assignment's own Q2/Q3 split — not an invented boundary. 93 94 -------------------------------------------------------------------------------- 95 WHERE I'D CALL "TOO CLEVER" ON MYSELF (blind spot, named up front) 96 -------------------------------------------------------------------------------- 97 - internal/api and internal/connector are NOT further split into sub-packages 98 (no internal/api/handlers vs internal/api/middleware). At this project's size 99 that split would be abstraction for its own sake — one package per binary- 100 facing concern is enough. If Marisol's optimist case is "one binary, less 101 ceremony," the honest counter isn't "more packages," it's: the two-binary 102 split earns its keep on threat model + scaling, not on package count. If that 103 reasoning doesn't hold, the diagram — not my opinion — changes. 104 ================================================================================ 105 ``` 106 107 Note: the diagram predates the ruling's addition of `internal/app` (shared bootstrap) below — that package sits alongside `internal/config` as a second package both binaries import (route registration + config wiring only, still no behavior shared beyond wiring). 108 109 ## Status 110 111 Ruled. Diagram delivered and appended above. 112 113 --- 114 Model: sonnet (Marisol Ferran, optimist; Nolan Reyes, pessimist; Renata Cole, lead, pragmatist + ruling). Turns consumed: 2 hire turns + lead synthesis. 115
{
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/observability.md"
}1 # Observability + Never-Log Enforcement
2
3 Design-only. Covers logging, metrics, and tracing for both services, and — the harder half — how `handoff-04-secrets.md`'s never-log list gets enforced as a mechanism, not just stated as a policy.
4
5 ## Logging
6
7 Structured logging (`slog`, Go stdlib — no third-party logging library needed for two services this size), JSON output, one line per event. Every log line that touches a request carries `sub` (caller identity), the endpoint, status, and a request id — never the request or response body wholesale. This is the pattern the never-log list requires (hand-off item 8: "full request bodies of `POST /auth` and `POST /identity`... both contain items above by construction") — the enforcement mechanism is that handlers log a small, explicit field set assembled by hand, never `log.Printf("%+v", req)` or equivalent reflection-based dumps of a request/response struct. A struct that contains a password field can't leak through a log statement that never receives the struct.
8
9 **Enforcement beyond "write it carefully":**
10 - A lint rule (a `golangci-lint` custom check, or a simple `go vet`-style grep in CI) that flags any log call passing a whole request/response struct, a `*http.Request` body, or any variable named/typed to match the never-log list's fields (`password`, `token`, `secret`, `dsn`, `client_secret`). Not foolproof — a determined developer can rename around it — but it catches the accidental case, which is the common one.
11 - Error-path discipline: wrapped errors are logged with `err.Error()` truncated/sanitized where the error type is known to carry sensitive detail (a DB driver error can echo the DSN; a vendor HTTP client error can echo the response body per hand-off item 4) — the wrapping layer strips those before the error reaches a log call, not the log call itself deciding case-by-case.
12
13 ## Metrics
14
15 Standard RED metrics (rate, errors, duration) per endpoint, via Prometheus client library, scraped by whatever the lab cluster already runs (not stood up new for this take-home). Labels: method, route, status class — never a label with cardinality tied to user data (no `sub` or phone number as a metric label; that turns a metrics backend into an accidental PII store with no access control at all, which is a worse leak than a log line since metrics systems are typically more widely readable).
16
17 ## Tracing
18
19 Not built out here — a two-service take-home with no running collector doesn't get more credible by naming OpenTelemetry. If ever added, it follows the same field discipline as logging.
20
21 ## The never-log list, operationalized
22
23 Hand-off's nine items map to two enforcement surfaces, not nine separate mechanisms:
24
25 1. **Application-level:** the structured-logging discipline above (explicit field sets, the lint check, error-wrapping sanitization) covers items 1, 2, 5, 6, 7 (password, tokens, JWTs, client secrets, DSN) — these never enter a Go `log`/`slog` call in the first place.
26 2. **Proxy/ingress-level:** item 9 ("search query parameters containing a name or phone fragment... redact or hash before any access log, including proxy and ingress logs 04 controls") is the one item application code can't fully own, since ingress access logs are written by the ingress controller, not the service. Mechanism: an ingress-level log-format override that logs the request path with query string stripped (or a regex redaction on the `phone`/`name` query params specifically) rather than the default full-URL access log format. This is a one-time ingress ConfigMap change, not a per-request code path.
27 3. Item 3 (PII field values from `/identity` or `user_profile`) and item 4 (raw vendor error bodies) are the same discipline as #1 applied to the connector specifically — the connector logs "called vendor X, got status Y," never the response body.
28
29 ## Audit-log access is a separate grant from Secret access
30
31 Per the hand-off's Kubernetes-specific requirements: the log stream carrying `sub`/scope/record-id/timestamp (the "fact of access" the never-log list still permits) is itself sensitive enough to reconstruct who looked up whom, so it gets its own RBAC — a distinct, narrower `Role` bound to an incident-review group, separate from any ServiceAccount's `Secret`-read grant (`./secrets-delivery.md`). Mechanism: if logs go to a cluster-local sink (e.g., Loki), that sink's read API gets its own RBAC/auth in front of it, not the same one guarding `kubectl get secrets`; if logs go to an external platform, the equivalent is a scoped read-only role in that platform, provisioned separately from any cluster credential.
32
33 ## Retention-sweep observability (05's S5 requirement, final)
34
35 Per `../05-data-ops/pii-governance.md`: the retention sweep must emit, per data class per run, three metrics — rows examined, rows deleted, and **the age of the oldest surviving row in that class**. An alert fires when that age exceeds the class's stated retention window plus one sweep interval. This is the one metric in this document tied to a specific reasoning worth repeating: a sweep job that silently stops running produces no error and no symptom on its own — "rows deleted" can't tell a broken sweep from a legitimately empty one, since both report zero. Oldest-surviving-row age is the one number that keeps climbing when the job is dead and holds steady when it's healthy, which is why it's the alerting signal, not just a nice-to-have metric alongside the other two.
36
37 Mechanism: the sweep job (whatever invokes it — a CronJob against the lab cluster, per 05's mechanism-agnostic requirement) emits these as Prometheus metrics with a `class` label (one label value per retention class, e.g. `vendor_token_cache`, `idp_identity_cache` — never a label carrying the PII itself, same discipline as the RED-metrics rule above) rather than only a log line, since a log line nobody's alerting on has the same "silent failure" problem the requirement exists to solve. The alert rule is `oldest_surviving_row_age_seconds{class="X"} > (retention_window_seconds + sweep_interval_seconds)`, evaluated per class.
38
39 ## What's deliberately not here
40
41 No SLOs/error-budget policy, no alerting-rule catalog, no log-retention-window design (that's 05's PII-retention territory for anything log-adjacent to PII, per `../05-data-ops/pii-governance.md` — this document only says logs must exclude PII values in the first place, not how long the remaining metadata is kept). A take-home needs the shape of an observability approach and a credible enforcement mechanism for the one hard requirement (never-log); it doesn't need a full SRE runbook.
42
43 ---
44 *AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2's never-log list. Bree's (capability-case) and Callum's (scope-cut) per-deliverable notes reviewed and folded in inline above, per PLAN.md §3.*
45 {
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/pii-governance.md"
}1 # PII Data Governance Note 2 3 Owner: 05-data-ops. Covers `user_profile` retention, IDP-cached PII (question 3's `/identity` response), and how retention interacts with `user_credential`. Threat modeling and access control for this data are `02-ai-security-architecture`'s call, not restated here — see `../02-ai-security-architecture/CLAUDE.md`. 4 5 ## Data minimization 6 7 The schema collects only the fields the assignment names: name, phone, and the specific address subfields the `/identity` endpoint returns (`street_address`, `locality`, `region`, `postal_code`, `country`). No speculative fields (e.g., date of birth, government ID) were added. If a future requirement needs more PII, that's a deliberate schema change and a deliberate governance decision, not a default to expand into. 8 9 ## Retention 10 11 `user_profile` rows are not retained indefinitely by default. Two clocks apply, distinguished by the `source` column: 12 13 - **`source = 'direct'`** — profile created through this system's own registration flow. Retention window is a product/legal decision this track doesn't own outright, but the schema supports enforcing whatever window is chosen: `created_at`/`updated_at` plus a scheduled deletion job keyed on those columns. 14 - **`source = 'idp_cache'`** — profile hydrated from a third-party IDP connector's `/identity` response. This data is subject to the **same retention obligation as directly-collected PII** — caching it does not reset or relax that obligation. If anything it adds a vendor-processor relationship on top: this system is now downstream custodian of another party's collected PII, which should be documented as a data-flow (third-party collection → this system → disposal) rather than left implicit. 15 16 Tagging cache rows distinctly by `source` is what makes a targeted deletion request or a bulk retention sweep possible without touching directly-collected profiles by accident. 17 18 ## What must never happen, structurally enforced 19 20 - **`user_credential` never carries PII.** No name, phone, or address column exists on that table, and the `UserCredential` type described in `multi-db-strategy.md` has no fields that could hold it. A credential-store breach exposes usernames and hashes, not addresses. 21 - **`user_profile` never carries a secret.** No password hash or raw secret column exists on that table. A profile-store breach exposes PII, not authentication material. 22 - **The third-party IDP's own `/auth` credentials (the `username`/`password` this system POSTs to the vendor's `/auth` endpoint) are never persisted in `user_credential` or anywhere else in this schema.** That table is for this system's own login credentials. Storing a vendor's auth secret here would mean this system is custodian of a third party's credential material with no assignment requirement to do so — a clear scope and governance violation, not just a modeling mistake. 23 24 This separation is treated as a governance control, not merely a normalization convenience: it's the reason a breach of one table doesn't cascade into exposing the other class of data. 25 26 ## Retention windows (S5) 27 28 **Every number below is a proposed POC default pending a product/legal ruling.** One asymmetry belongs stated plainly rather than buried: GDPR Art. 5(1)(e) storage limitation is a **principle** — data kept "no longer than is necessary" — demanding a window someone justifies. It is not a number anyone can look up. So every basis below is either an engineering rationale or the honest word *unknown*. There is no third kind, and a citation implying a regulation supplies a specific duration would be a false statement in a client-facing document. 29 30 | Data class | Clock column | Proposed window | Basis | Disposal | What cascades | Ratifier | Status | 31 |---|---|---|---|---|---|---|---| 32 | `user_profile`, `source='direct'` | `updated_at` | **24 months** since last update | *unknown — needs product/legal ruling.* Engineering basis only: long enough that a dormant account surviving a slow re-engagement cycle is not destroyed, short enough to be a defensible answer to "why do you still hold this." No regulation supplies this number | Hard delete | Cascade FK destroys all `user_credential` rows — the account stops existing | unknown — no named legal owner on this POC | Proposed | 33 | `user_profile`, `source='idp_cache'` | `updated_at` | **30 days** since last hydration | Engineering: **a cache is not a record.** Re-hydration costs one `/identity` call, so deleting too early costs a vendor round-trip. Vendor data also goes stale — a 14-month-old cached address is a correctness problem before it is a privacy one. We are downstream custodian of another party's collected PII with no independent relationship to the subject, which argues for the shortest window that still functions | Hard delete | Cascade FK; a cached profile should not normally have credentials — if it does, it is no longer merely a cache and product must say which clock wins | unknown | Proposed | 34 | `user_profile`, `source='idp_cache'`, **no referencing `user_credential`** (orphaned cache) | **`created_at`**, deliberately not `updated_at` | **7 days** since creation | Engineering: lookup residue, not an account — someone called `/identity`, we cached the answer, nothing was ever built on it. **The clock is `created_at` because `updated_at` is touched by the read and re-hydration path: an orphan re-read on any schedule would keep resetting its own expiry and never die.** Closing that loop is the entire reason this row exists | Hard delete | Nothing — by definition it has no children | unknown | Proposed | 35 | `user_credential` | — | **No independent window** | Wholly governed by its profile's window via the cascade FK. A credential cannot outlive its profile; a credential row with no profile cannot exist. A second clock could only ever disagree with the first, with no resolution rule | n/a | n/a | **Ruled** | 36 37 ### Two consequences that surprise people, stated loudly 38 39 1. **Deleting a profile silently destroys its credentials.** The account ceases to exist; there is no "profile deleted, login preserved" state. Deliberate. 40 2. **The reverse does not hold.** Deleting a credential leaves the profile standing. **A user who removes their last login method still has PII in this database, running on the `direct` clock.** If product wants "removing your last credential deletes the account," that is a policy decision, not a schema one — and it is currently *not* what the schema does. 41 42 ### The cache window is "since last use", not "since first seen" 43 44 Re-hydration moves `updated_at`, so an actively-used cache row can live indefinitely. That is correct, and it must be written down, because it means the 30-day window measures **time since last use**. It is also exactly why the orphan row is keyed on `created_at`: the two rules are one rule seen from two sides — a row that is still being used should survive, and a row that is only being *re-read by the sweep's own neighbours* should not be able to refresh itself into immortality. 45 46 ## The deletion mechanism (S5) 47 48 | Trigger | Selection predicate | Scope | Effect on `user_credential` | Disposal | Evidence left behind | What 04 provides | Failure mode if it never runs | 49 |---|---|---|---|---|---|---|---| 50 | **Bulk retention sweep** (scheduled) | `source` = the class, plus that class's clock older than that class's window; orphan class additionally requires no referencing credential. **One class per pass — never a mixed-class sweep**, because a single predicate spanning two clocks is how the wrong window gets applied to the wrong rows | Many rows | Cascade delete, implicit and unmentioned in the predicate — the sweep selects profiles and destroys credentials as a side effect. Stated because it is invisible at the point of use | **Hard delete** | One row in a separate `deletion_log`: profile UUID, `source`, `deleted_at`, reason `retention_sweep`, job run id. **No name, phone or address** | Schedule it; run it per class; emit the metrics below | **Nothing errors.** No exception, no alert, no symptom — rows accumulate and the policy is fiction nobody can see is fiction | 51 | **Subject-deletion request** | Exactly one profile id, resolved from the subject's identifier *before* the job runs — resolution is a lookup, not part of the deletion predicate | One profile plus its cascade | Cascade delete — the correct reading of "delete my data" | Hard delete, **same code path as the sweep** | Same `deletion_log` row, reason `subject_request`, plus the external request reference | An operator-invocable path that is **not** "someone runs SQL by hand against production" | Legal exposure rather than silent drift — this one gets noticed, which is why it must not be the only mechanism that works | 52 | **IDP re-hydration overwrite** | Existing `idp_cache` profile matched on the lookup key, fresh `/identity` response | One row | **None** — the profile id is stable across re-hydration, so credentials survive | Upsert in place; old field values are **gone, not versioned** | None, and none wanted | Nothing — request-path behaviour, not a job | n/a — but see the clock-reset note above | 53 54 **Why hard delete and not a soft-delete flag.** A flag means the PII is still sitting in the table, and every query written from that day forward must remember to filter it out. That is how a retention policy becomes decorative while still passing its own tests. 55 56 **Why subject deletion reuses the sweep's code path.** A separate "real delete" path used rarely is a path that is broken when you need it. 57 58 **Why the `deletion_log` holds no PII.** Otherwise the log is just a second copy of the problem with no clock on it. The retained UUID identifies a row that no longer exists and is not linkable to a person once the profile is gone. 59 60 ### The health check, which is a requirement rather than a suggestion 61 62 A deletion job that stops running produces no error, no alert and no visible symptom. The only evidence is data that should be absent and isn't. So the sweep emits, per class and per run: **rows examined, rows deleted, and the age of the oldest surviving row in that class.** 63 64 The third is the actual health check. **04 should alert when the age of the oldest surviving row exceeds that class's window plus one sweep interval** — that is the condition that detects a job which has silently stopped, which rows-deleted counts cannot. 65 66 ### Explicitly not modelled: derived data 67 68 Flagged by the hire who wrote the tables above, against her own stated blind spot — everything in them became a row because it fit a column, and this did not. 69 70 **A subject-deletion request currently reaches `user_profile` and, by cascade, `user_credential`. It does not reach derived data** — application logs, metrics, connector traces, or anything else that may have incidentally captured a name, phone or address in transit. That data has no `source` column, no retention clock, and in most cases no primary key to select on. 71 72 This is **not** a decision that derived data is out of scope for deletion. It is an unmodelled area, recorded as such so that its absence is not mistaken for a ruling. It sits across `../02-ai-security-architecture/` (what may be logged at all, and the never-log list) and `../04-infra-devops/` (log retention and where those logs physically live). The cheapest mitigation is upstream of this track entirely: if PII never enters a log, there is nothing to delete from it — which makes 02's never-log list the real control here, not a deletion job this track could specify. 73 74 ## Handoff notes 75 76 - `02-ai-security-architecture` should build credential hashing (algorithm, cost factor, salt handling) on top of the `secret`/`hash_algo`/`hash_cost` columns already reserved in the schema — flagged directly to Marcus once this schema landed. 77 - `04-infra-devops` owns where retention sweeps and deletion jobs actually run (cron, scheduled job, pipeline step) — this note specifies the policy shape, not the execution mechanism. 78
{
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/decisions/context-propagation.md"
}1 # Decision: Context propagation into the DAO layer 2 3 Pattern: rotating devil's advocate, decision 2 of 3 (seat: Nolan Reyes). Proposer: Renata Cole. 4 5 ## Proposal 6 7 Every `Repository` method (and its three sub-interfaces — `Profiles`/`Credentials`/`Methods`) takes `context.Context` as its first argument, propagated unbroken from the HTTP request through the handler into every DB call, so request cancellation/timeout actually cancels in-flight queries. 8 9 ## Objections — Nolan Reyes (Veteran) 10 11 Agreed with the direction but pushed on three failure modes from production experience: 12 13 1. **Cancellation racing a multi-statement write.** A cancelled `SELECT` aborts cleanly; a cancelled multi-statement write (e.g., profile + credential saved together) risks a half-written row unless cancellation is scoped to a transaction, not a statement. Cited a migration job killed mid-batch by a deploy SIGTERM, leaving orphaned rows because cancellation was handled per-statement. 14 2. **Deadline budget, not a boolean.** Unbroken propagation means an inherited, already-partially-consumed deadline reaches the DAO call. If upstream work (e.g., a connector call) already burned most of the HTTP handler's timeout, the DAO gets a shrinking remainder and starts timing out under *normal* latency, not just failure — the good case starts looking like the bad case. 15 3. **Sub-interfaces are not uniformly cancel-safe.** Credential writes during auth are exactly the path that caused a real incident: a retry re-issued a credential write against an already-expired derived context and silently no-op'd instead of erroring. Wants explicit naming of which write paths deliberately detach from the inbound context (via `context.WithoutCancel` or a fresh background context) for durability, distinct from read paths that don't need it. 16 17 ## Ruling (Renata Cole) 18 19 All three accepted; no decline. 20 21 1. Any `Repository` method that writes to more than one table (e.g., a profile+credential save) runs inside a single `Tx`. Context cancellation during that transaction triggers `Tx.Rollback`, never a partial commit — this is a stated requirement on the `Repository` implementation contract, not left to each backend's discretion. 22 2. Deadline propagation is a budget-allocation decision, stated explicitly in S3's middleware design: the HTTP-boundary timeout must be sized to cover realistic p99 DAO latency *plus* any upstream work (e.g., no DAO call should ever be handed a near-zero remaining deadline as a matter of routine operation). This gets a concrete number attached when S3's middleware design is written, not left as "some deadline." 23 3. Credential writes and any token-persistence write are named explicitly as the paths that use `context.WithoutCancel` (or an equivalent fresh background context with its own bounded timeout) for the actual commit — distinct from profile reads and other cancel-safe paths, which propagate the inbound context unmodified. This distinction is documented at the point each such method is implemented, not left implicit. 24 25 ## Status 26 27 Ruled. Carries into S2 (Repository implementation contract: transaction scoping, detach points) and S3 (middleware deadline budget). 28 29 ## Resolved against 05's contract (2026-09-12) 30 31 Oren's first-pass review of 05's DAO contract flagged a real tension: this ruling's item 1 requires any multi-table write to run in a single `Tx`, but the contract as first landed had no method spanning both `user_profile` and `user_credential`, and explicitly barred 03 from composing a cross-call transaction — leaving registration's profile+credential creation with no atomic path at all. 05 resolved it by adding `CreateProfileWithCredential(ctx, p, c)` on the `Repository` composite (05's §3b) — the one cross-table operation in the contract, DAO-generated IDs for both rows, single transaction, `c.UserID` ignored on input and set from the just-created profile. This ruling's item 1 now has exactly one caller-visible method satisfying it, rather than being a requirement with no concrete instance. 32 33 --- 34 Model: sonnet (Nolan Reyes, objection seat; Renata Cole, lead, proposer + ruling). Turns consumed: 1 hire turn + lead synthesis. 35
{
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/decisions/search-authz-scoping.md"
}1 # Decision: Search-API Authorization Scoping — Adversarial Pair (S4) 2 3 Pattern: adversarial pair, amended per `CASTING.md` §5 ⟨02⟩. Surface: the scope vocabulary, `profile:search` provisioning, object-level policy, pagination/rate limits, and grant lifecycle for the REST API in `api-auth-design.md`. This record is the full positions and rebuttal; the ruling is folded directly into `api-auth-design.md`. 4 5 ## Seats 6 7 - **Proposer — Helena Marsh.** Drafts the scoping proposal. 8 - **Skeptic — Ingrid Solano.** Numbered objections, each with a "what would change my mind" line. 9 - **Proposer — Helena Marsh.** Answers every objection in writing. 10 - **Lead — Marcus Ilori.** Rules. 11 12 ## Proposer: Helena's initial draft 13 14 **Scope vocabulary**, sharpened from `api-auth-design.md`'s original sketch: `profile:read:own` (id lookup, constrained to a `caller_referral` mapping — not a search), `profile:read:any` (id lookup, no referral constraint), `profile:search` (fuzzy name/phone query, its own gated capability, not "read:any plus search"). 15 16 **`profile:search` provisioning bar (all four required):** named service identity, not a shared team credential; documented business function on file before provisioning; change-controlled approval with a named human approver, logged; tighter rate/result limits than any other scope. 17 18 **Object-level policy hook:** `authorize(sub, scope, target)` runs after the scope check — `read:own` checks `caller_referral` membership; `read:any` requires a logged `reason_code`; `search` additionally enforces a result cap, rejecting outright (not truncating) a query that would exceed it unconstrained. 19 20 **Pagination/rate limits:** cursor-based (not offset), default page 20 / ceiling 50; rate limits 60 req/min for `read:own`/`read:any`, 10 req/min for `search`, with alerting on sustained near-limit usage. 21 22 ## Skeptic: Ingrid's five objections 23 24 1. **Scope vocabulary built before callers were listed.** No enumerated table of who actually calls each scope — can't tell whether `read:any` reflects a real caller or is invented for symmetry with `read:own`. *Would change my mind:* a table of named calling services/roles mapped to the scope each needs, before finalizing. 25 2. **`reason_code` for `read:any` is logged but not bounded to a controlled vocabulary.** A freeform string is worthless in an audit six months later; `search`'s provisioning bar is rigorous while `read:any`'s control is an order of magnitude looser for a scope that's *less* constrained at query time. *Would change my mind:* a closed enum of reason codes, same rigor as the search bar. 26 3. **`caller_referral` membership has no stated owner or change process.** `read:own` leans entirely on this mapping with no named approver or re-approval, while `search` gets a four-point bar — the narrowest scope is the softer target, not the safer one. *Would change my mind:* the same named-approver-plus-re-approval discipline applied to `caller_referral` changes, or a citation for why it needs less. 27 4. **"Reject outright" on an over-cap query stops single-request enumeration, not decomposition.** A caller at 10 req/min can walk the namespace in under-cap slices and reassemble the population over time; alerting only covers sustained near-limit usage, which a slow enumerator won't trigger. *Would change my mind:* a cumulative-distinct-subjects-per-caller-per-window counter feeding the same alerting path, or evidence that the provisioning bar makes slow enumeration commercially irrational for anyone who'd clear it. 28 5. **90-day re-approval is a calendar control, not a lifecycle control.** Nothing ties revocation to offboarding or role-change; a grant can survive up to 89 days past when it should have been pulled. *Would change my mind:* an explicit statement that revocation is event-triggered with the 90-day cycle as backstop only, or a citation that no such feed exists and the backstop is an accepted residual risk, named as such. 29 30 ## Proposer: Helena's answers 31 32 All five accepted; none declined. 33 34 1. **Revised.** `profile:read:any` now requires an entry in an explicit caller-role table (service name, business justification, and why `read:own`'s referral constraint is insufficient for that caller) before the grant is issued — gated the same way `profile:search` already is. If no caller meets the bar, the scope stays defined but unissued, since "any known record, no referral, audited" is a real access pattern some back-office functions need, but nobody gets it by default. 35 2. **Revised.** `reason_code` replaced with a closed, versioned enum (`support_ticket`, `fraud_review`, `kyc_reverification`, `legal_hold`) defined in `api-auth-design.md`; adding a value requires a documented change, matching the `search` provisioning bar's rigor. 36 3. **Revised — the sharpest of the five.** `caller_referral` entries are data owned by `05-data-ops`, written only through the same partner-onboarding workflow as any other provisioning decision: named approver, logged, reviewed on the same cadence as scope grants. No ad hoc inserts outside that workflow. 37 4. **Revised.** Added a cumulative distinct-record-touch counter per caller per rolling window (e.g., no more than N distinct profiles returned to one credential per 24h without a fresh `reason_code`/justification), tracked independently of per-minute rate — catches decomposition regardless of how slowly it's spread. Counting-store mechanism is 03/04's to build; the requirement (cumulative, not just per-minute, measurement) is asserted here. 38 5. **Revised.** All three scope grants — not just `search` — are revoked immediately, ahead of the 90-day cycle, on: the named approver's offboarding, the caller's business function changing, or a 30-day idle trigger (no usage → revoke pending re-justification). Requires 03/04 to consume an offboarding/ownership-change signal; the requirement (lifecycle-triggered revocation, not solely calendar) is set here, the plumbing is theirs. 39 40 ## Ruling (Marcus Ilori) 41 42 Accepted in full — Helena's revised proposal is the design. All five of Ingrid's objections identified real gaps (asymmetric rigor between scopes, a control with no owner, a decomposition path the rate limit didn't cover, a calendar-only lifecycle control) and every fix closes the specific gap named rather than gesturing at a general improvement. Ingrid's objection 3 (`caller_referral` ownership) was the strongest single point in this run — the narrowest-privilege scope having the weakest control around it is exactly the kind of asymmetry an adversarial pair exists to catch, and a Proposer working alone would have had no structural reason to notice it. Folded into `api-auth-design.md` directly; this is the scope vocabulary, provisioning bar, and lifecycle rule `handoff-03-auth.md` (S6) builds on next. 43 44 --- 45 Model: sonnet (Helena Marsh, Proposer) / sonnet (Ingrid Solano, Skeptic) / sonnet (Marcus Ilori, lead, ruling). Turns consumed: 3 + lead (draft, objections, answer, ruling) — matches the 3-turns-plus-lead budget in `PLAN.md` §3. 46
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/multi-db-strategy.md"
}1 # Multi-Database Abstraction Strategy
2
3 Owner: 05-data-ops. Implemented by: `03-engineering-delivery`, from the prose contract below — this project is still in planning phase, so the interface and schema exist here as design, not as `.go`/`.sql` files, until there's an explicit go-ahead to write code. Full research trail: `research-data-ops-best-practices.md`.
4
5 ## The interface contract (prose, not code)
6
7 Three repository interfaces, one per entity, each implemented by two backend packages (below) and never containing SQL themselves — the service layer depends only on these shapes:
8
9 - **`ProfileRepository`** — `Get(ctx, id) (*UserProfile, error)`; `Search(ctx, query) (results []UserProfile, total int, error)` where `query` filters by name (partial/fuzzy), phone (exact/prefix), region, country, plus limit/offset for pagination; `Create(ctx, *UserProfile) (*UserProfile, error)`; `Update(ctx, *UserProfile) (*UserProfile, error)` implemented as a portable upsert; `Delete(ctx, id) error`.
10 - **`CredentialRepository`** — `GetByUsername(ctx, username) (*UserCredential, error)` as the primary auth lookup path; `ListByUserID(ctx, userID) ([]UserCredential, error)`; `Create`, `Update`, `Delete` mirroring `ProfileRepository`'s shape. Never accepts or returns `UserProfile` fields.
11 - **`AuthMethodRepository`** — `Get(ctx, id)`, `GetByName(ctx, name)`, `List(ctx, activeOnly bool)`. Read-heavy; writes to this table are an operational action (adding a supported method), not part of the normal request path.
12
13 Domain types implied by the above: `UserProfile` (`ID`, `Name`, `Phone`, `StreetAddress`, `Locality`, `Region`, `PostalCode`, `Country`, `Source` — `"direct"` or `"idp_cache"` — `CreatedAt`, `UpdatedAt`); `UserCredential` (`ID`, `UserID` FK, `Username` unique, `MethodID` FK, `Secret` as bytes/nil, `HashAlgo`, `HashCost`, `CreatedAt`, `UpdatedAt`); `AuthMethod` (`ID`, `Name`, `RequiresSecret` bool, `IsActive` bool). A shared `ErrNotFound` sentinel is returned by every backend implementation for a missing row, so the service layer never has to distinguish `sql.ErrNoRows` from a SQLite-specific miss.
14
15 Two concrete implementations satisfy all three interfaces:
16
17 - `postgres` package — serves both PostgreSQL and CockroachDB from one implementation. They share enough of the wire protocol and SQL surface that a second implementation would be duplicated effort — **but that is only half the argument, and the half that was originally stated here was the cheap half.** Sourced in `research-cockroachdb-postgres-semantics.md`: CockroachDB defaults to SERIALIZABLE and returns client-retryable `40001` errors that PostgreSQL at READ COMMITTED never emits, and constraint-violation error *text* is not portable between them. One package serves both **only if it carries an explicit engine-aware retry-and-error seam** (see the contract's error semantics and write-path sections). It cannot be a thin PostgreSQL implementation pointed at a second connection string.
18 - `sqlite` package — a separate implementation. SQLite diverges enough (no native UUID/BOOLEAN, no trigram index, app-side ID generation) that forcing it through the Postgres implementation would mean littering that code with backend conditionals, which is the exact failure mode a repository-per-backend split is meant to avoid.
19
20 The service layer depends only on the interfaces and is constructed with whichever concrete implementation the startup config names. Backend choice is a wiring decision, never a runtime branch inside business logic.
21
22 ## Why not one implementation with dialect branches
23
24 **Corrected 2026-09-12 after the three-hats run — full reasoning in `decisions/multi-db-abstraction.md`.** This section previously rejected dialect branches as "`if backend == \"sqlite\"` scattered through query construction." That describes a schema we do not have. Of the six concerns in the dialect table below, exactly **two** survive as real conditionals — app-side PK generation for SQLite, and fuzzy name search. Placeholders are the driver's problem, upsert syntax is identical across all three, JSON is unused, booleans map at the boundary. It is two `if`s, not a scattering.
25
26 The two options therefore cost roughly the same, and the choice between them is made on **reviewability, not cost**: two parallel implementations a reviewer can diff against each other read better than one file with two apologetic conditionals, and this submission is graded on design reasoning. That is a legitimate tiebreak, and it is stated as what it is — a reviewer who counts the conditionals finds the discrepancy in seconds, and claiming a cost advantage that does not exist would be worse than claiming nothing.
27
28 The real risk of the chosen shape is **silent behavioural divergence**: the interface guarantees both packages compile, not that they behave alike, and the failure mode produces no error, just different results. See the conformance-suite requirement in the contract below — the ruling is conditional on it.
29
30 ## Dialect differences that survive into the DAO layer
31
32 | Concern | Postgres / CockroachDB | SQLite | Handling |
33 |---|---|---|---|
34 | Primary key | `UUID DEFAULT gen_random_uuid()`, server-generated | `TEXT`, app-generated before insert | DAO layer generates the UUID in Go for SQLite; server-generates for Postgres/CockroachDB. Callers never see the difference — `Create()` returns the populated `ID` either way. |
35 | Placeholders | `$1, $2, ...` | `?` | Handled by the driver/query builder, not hand-rolled per query. |
36 | Upsert | `INSERT ... ON CONFLICT (id) DO UPDATE SET ...` | Same syntax, supported since SQLite 3.24 | One query shape used everywhere. CockroachDB's `UPSERT` shorthand is deliberately not used, to avoid a second query dialect. |
37 | Fuzzy name search | `pg_trgm` GIN expression index on `LOWER(name)`, native on both Postgres and CockroachDB | No native equivalent; `LIKE` scan | **Corrected 2026-09-12 — see `decisions/multi-db-abstraction.md`.** This was previously described as a pure performance gap, on the claim that `Search()` "behaves identically from the caller's side". That was false: SQLite's `LIKE` is case-insensitive for ASCII by default, Postgres's is case-sensitive, so the same search returned *different row sets* per backend with no error. The contract now folds case explicitly on both sides (`LOWER(col) LIKE LOWER(pattern)`), which closes it for ASCII input. Residual, accepted and named: Postgres `lower()` is locale-aware, SQLite's is ASCII-only, so non-ASCII folding still diverges. |
38 | JSON | `JSONB`, indexable, has query operators | `TEXT`/`BLOB` with a differently-formatted internal `JSONB` (SQLite 3.45+) that is not disk-compatible with Postgres's | Not currently used in the reference schema. If a raw IDP `/identity` payload is ever cached, it is stored as opaque `TEXT` at the DAO boundary — no JSON-operator filtering pushed into either implementation, so the column stays portable. |
39 | Boolean | Native `BOOLEAN` | `INTEGER` 0/1 | Mapped at the DAO boundary; Go `bool` on both sides of the interface. |
40
41 ## Schema shape (prose, not DDL)
42
43 `auth_method` — lookup table, not a native enum: `id` (UUID), `name` (unique — `"password"`, `"passkey"`, `"otp"`, `"oidc"`, ...), `requires_secret` (bool, false for passkey/WebAuthn-style methods), `is_active` (bool), `created_at`. Seeded with one `"password"` row; new methods are inserted as data, never a migration.
44
45 `user_profile` — pure PII, no secrets: `id` (UUID PK), `name`, `phone` (normalized E.164 at write time), `street_address`, `locality`, `region`, `postal_code`, `country` (ISO 3166-1 alpha-2), `source` (`"direct"` or `"idp_cache"`, constrained), `created_at`, `updated_at`.
46
47 `user_credential` — this system's own login credentials, no PII: `id` (UUID PK), `user_id` (FK → `user_profile.id`, cascade delete), `username` (unique), `method_id` (FK → `auth_method.id`), `secret` (nullable, salted hash only, never plaintext), `hash_algo`, `hash_cost`, `created_at`, `updated_at`.
48
49 Indexes: B-Tree on `user_profile.phone`; trigram GIN on `user_profile.name` (Postgres/CockroachDB only); unique on `user_credential.username`; FK indexes on `user_credential.user_id` and `.method_id`.
50
51 Primary keys are UUID everywhere — server-generated (`gen_random_uuid()`) on Postgres/CockroachDB, app-generated in Go before insert on SQLite (which has no native UUID type). One PK strategy across backends, no per-backend branch in the DAO.
52
53 ## Migration approach
54
55 - One shared SQL migration series covers the base schema (`CREATE TABLE`, standard column types, the portable upsert-compatible constraints) since it's close enough across all three backends for this schema's scope.
56 - A small, explicitly-named set of per-backend override files covers what genuinely can't be shared — currently exactly one: the `pg_trgm` GIN index on `user_profile.name`, which has no SQLite equivalent and is simply omitted there rather than faked.
57 - Recommended tool: **goose** — plain-SQL-first, minimal dependencies, and its style fits "mostly shared, occasionally per-backend" files more naturally than golang-migrate's stricter up/down pairing. golang-migrate is an equally defensible choice if the engineering track already has tooling preferences; Atlas is worth naming as the more sophisticated schema-as-state alternative but is more than this take-home's scope needs.
58 - Migration files live under version control alongside the DAO code (`03-engineering-delivery` owns the pipeline that runs them — see `../04-infra-devops/CLAUDE.md` for where/how they're actually executed in CI/CD).
59
60 ## What the engineering track should take from this
61
62 1. Implement `ProfileRepository`, `CredentialRepository`, `AuthMethodRepository` per the interface contract above, once there's a go-ahead to write code — don't redesign the contract, flag back to this track if it doesn't fit.
63 2. Two packages: `postgres` (serves Postgres + CockroachDB) and `sqlite`. No third implementation needed unless a fourth backend is added to the assignment.
64 3. Use the schema shape above as the starting migration content for both backend families.
65 4. Never let `UserCredential` and `UserProfile` fields mix in either implementation, even as an internal convenience struct — that boundary is a governance control (see `pii-governance.md`), not just a modeling choice.
66
67 ---
68
69 # The Go-shaped contract — the 05→03 hand-off
70
71 *(Story S1 in `./PLAN.md`: the DAO interface and schema contract. Where this document says S2 it means the `Search()` query-shape ruling, `decisions/search-query-shape.md`; S3 means the multi-database abstraction ruling, `decisions/multi-db-abstraction.md`.)*
72
73 **Status: stable.** Stable means unlikely to be rewritten, not polished — per `../02-ai-security-architecture/planning-approach.md` §2, 03 should start against this rather than wait. Written to the project's hand-off standard: **03 should be able to implement this without re-deriving my reasoning.** Where a decision has a rationale 03 might otherwise re-litigate, the rationale is here; where something is deliberately out of 03's scope, §6 says so and says why.
74
75 Everything below is Go-*shaped* prose inside a markdown file. No `.go` or `.sql` files exist or should exist until Warren's explicit go-ahead.
76
77 **Vocabulary used below, defined once here** (added after a cold read found every one of these assumed rather than explained):
78
79 - **SQLSTATE** — the five-character error code PostgreSQL returns for every error (`23505` = unique violation, `23503` = foreign-key violation, `23514` = CHECK-constraint violation, `40001` = serialization failure). CockroachDB implements the same table. **SQLite does not have SQLSTATE at all** — it returns extended result codes such as `SQLITE_CONSTRAINT_UNIQUE`. This asymmetry matters in §4.
80 - **GIN / `gin_trgm_ops`** — a PostgreSQL index type suited to "does this text contain that fragment" searches, and the operator class that makes it work on trigrams (three-character fragments). Together they make `LIKE '%smith%'` use an index instead of scanning every row.
81 - **`COLLATE "C"`** — tells the database to compare text by raw byte value rather than by language-aware alphabetical rules. Byte order sorts all uppercase before all lowercase; a locale-aware order does not. Picking one explicitly is what makes two different engines agree.
82 - **SERIALIZABLE / READ COMMITTED** — transaction isolation levels. SERIALIZABLE is stricter and can force the database to reject a transaction that would produce an inconsistent result, asking the client to retry.
83 - **`crdb.ExecuteTx`** — a helper from CockroachDB's official Go library that runs a transaction and automatically retries it when the database returns the retryable `40001` error. There is no PostgreSQL equivalent because PostgreSQL at its default isolation level does not produce that error.
84
85 Inputs that produced it: `decisions/search-query-shape.md` (S2), `decisions/multi-db-abstraction.md` (S3), `research-cockroachdb-postgres-semantics.md`, and a direct two-party agreement with 03 on the factory shape (below).
86
87 ## 1. The factory and the composite
88
89 Agreed directly with Renata Cole (03) on 2026-09-12. **Her `dao.New` signature is unchanged**; the composite is the resolution of what had been a silent mismatch between her single `Repository` return type and this track's three interfaces.
90
91 ```go
92 func New(driver, dsn string) (Repository, error) // driver: "postgres" | "cockroachdb" | "sqlite"
93
94 type Repository interface {
95 Profiles() ProfileRepository
96 Credentials() CredentialRepository
97 Methods() AuthMethodRepository
98
99 // CreateProfileWithCredential writes both rows in ONE transaction.
100 // The only cross-table operation in this contract. See §3b.
101 CreateProfileWithCredential(ctx context.Context, p *UserProfile, c *UserCredential) (*UserProfile, *UserCredential, error)
102
103 Close() error
104 }
105 ```
106
107 `Repository` stays an **interface**, not a struct with exported fields — Renata's reason, which is better than either I offered: an interface is mockable for her test strategy. The three accessors stay distinct rather than flattening into one interface because that separation is a governance control (`pii-governance.md`), not a modelling preference: a call site must reach for the credential accessor deliberately rather than find profile and credential methods side by side on one value.
108
109 `"postgres"` and `"cockroachdb"` both select the `postgres` package. They are distinct driver strings rather than one because the retry seam in §4 needs to know which engine it is talking to.
110
111 ## 2. Domain types
112
113 ```go
114 type UserProfile struct {
115 ID string // UUID
116 Name string // required, never empty
117 Phone *string // nil if unknown; E.164 when present
118 StreetAddress *string
119 Locality *string
120 Region *string
121 PostalCode *string
122 Country *string // ISO 3166-1 alpha-2, upper-case
123 Source string // "direct" | "idp_cache"
124 CreatedAt time.Time
125 UpdatedAt time.Time
126 }
127
128 type UserCredential struct {
129 ID string // UUID
130 UserID string // FK -> user_profile.id
131 Username string // unique, case-folded
132 MethodID string // FK -> auth_method.id
133 Secret []byte // nil unless SecretState == SecretSet
134 SecretState string // "none" | "set" | "revoked"
135 HashAlgo *string // non-nil iff SecretState == "set"
136 HashCost *int // non-nil iff SecretState == "set"
137 CreatedAt time.Time
138 UpdatedAt time.Time
139 }
140
141 type AuthMethod struct {
142 ID string
143 Name string // "password", "passkey", "otp", "oidc", ...
144 RequiresSecret bool
145 IsActive bool
146 CreatedAt time.Time
147 }
148 ```
149
150 **Pointers mean "absent", never "empty".** No field in this contract uses the empty string to mean absent. A `*string` that is non-nil and empty is invalid input, not a value — see §5.
151
152 **Who owns `ID`, `CreatedAt` and `UpdatedAt`: the DAO, always.**
153
154 - On `Create`, the caller leaves `ID` as `""`. The DAO generates the UUID (in Go for SQLite, server-side elsewhere) and returns the populated struct. **A non-empty `ID` passed to `Create` is a caller error and returns `ErrInvalidArgument`** — it is not a request to use that ID. This removes the contradiction a cold read found between "callers never see the difference" and `Create` returning `ErrAlreadyExists` "if the ID is taken": for profiles, the ID cannot be taken, because the caller never chooses it.
155 - `CreatedAt` and `UpdatedAt` are set by the DAO on every write and ignored on input. `UpdatedAt` is touched by `Create`, `Update`, and `Upsert` — including IDP re-hydration, which is load-bearing for retention (`pii-governance.md`).
156
157 **`SecretState` is new and is the fix for a real defect.** The previous prose said `secret` was "nullable, salted hash only" and stopped. Yusuf Karadag's review found that at least three distinct states were collapsing into one null — the method requires no server-held secret (passkey/WebAuthn, where the private key is client-side), the secret is not yet set, or the secret was revoked — and that `auth_method.requires_secret` was consequently load-bearing on nothing. An explicit state column makes the three distinguishable and gives `requires_secret` something to be checked against.
158
159 ## 3. The interfaces
160
161 ```go
162 type ProfileRepository interface {
163 Get(ctx context.Context, id string) (*UserProfile, error)
164 Search(ctx context.Context, q ProfileQuery) (results []UserProfile, total int, err error)
165 Create(ctx context.Context, p *UserProfile) (*UserProfile, error)
166 Update(ctx context.Context, p *UserProfile) (*UserProfile, error)
167 Upsert(ctx context.Context, p *UserProfile) (*UserProfile, error)
168 Delete(ctx context.Context, id string) error
169 }
170
171 type CredentialRepository interface {
172 GetByUsername(ctx context.Context, username string) (*UserCredential, error)
173 ListByUserID(ctx context.Context, userID string) ([]UserCredential, error)
174 Create(ctx context.Context, c *UserCredential) (*UserCredential, error)
175 Update(ctx context.Context, c *UserCredential) (*UserCredential, error)
176 Delete(ctx context.Context, id string) error
177 }
178
179 type AuthMethodRepository interface {
180 Get(ctx context.Context, id string) (*AuthMethod, error)
181 GetByName(ctx context.Context, name string) (*AuthMethod, error)
182 List(ctx context.Context, activeOnly bool) ([]AuthMethod, error)
183 }
184
185 type ProfileQuery struct {
186 Name *string // case-folded substring match; caller input escaped by the DAO
187 Phone *string // EXACT match on the E.164-normalized value (not prefix)
188 Region *string // case-folded exact match
189 Country *string // exact match, alpha-2, upper-cased by the DAO
190 Limit int // 1..100; 0 means "use default 50", deliberately
191 Offset int // 0..10_000
192 }
193 ```
194
195 ### `Search` semantics carried over from the S2 ruling
196
197 These were settled in `decisions/search-query-shape.md` and were missing from the first draft of this contract — the intent-versus-text gap a cold read was run to find.
198
199 - **`total` is the count of ALL rows matching the filters, ignoring `Limit` and `Offset`** — not the number of rows on this page. Exact, not estimated, computed by a second `COUNT(*)` out
… truncated (20 KB cap){
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/migration-approach.md"
}1 # Migration Approach 2 3 Owner: 05-data-ops (Priya Nandakumar). **Primary receiver: `../04-infra-devops/`** — this is one of the three inputs that track is gated on. Secondary receiver: `../03-engineering-delivery/`, which owns the migration files themselves alongside the DAO code. 4 5 Written to the project's hand-off standard: 04 should be able to act on this without re-deriving my reasoning. Where I state a *requirement*, the mechanism is 04's to choose; where I state a *mechanism*, it is because the requirement cannot be met without it, and I say why. 6 7 Status: **final.** The devil's-advocate objection turn (story S4 in `./PLAN.md`) ran and raised six objections; all six were accepted. The two I named as most worth attacking — the tool choice and the single-runner requirement — were indeed where it landed: R2's *conclusion* survived but its *reasoning* was wrong and has been replaced, and R3 was found to be checking one version number where §2 creates two. Dispositions: `./decisions/migration-approach-objections.md`. 8 9 ## 1. Tool: goose 10 11 **goose** — a Go command-line migration runner that applies numbered `.sql` files in order and records which have been applied in a version table inside the target database. Plain-SQL-first, minimal dependencies. Reasoning is in `./multi-db-strategy.md` and `./research-data-ops-best-practices.md` §3: this schema is "mostly shared DDL, occasionally per-backend", and goose's loose directory model fits that better than golang-migrate's stricter paired up/down files. golang-migrate is an equally defensible tool and I will not contest a swap if 03 already has tooling preferences — **nothing in §3–§6 below changes if the tool changes — with one named exception**: §2's two-invocation mechanism uses goose's `-table` flag, so a swap to golang-migrate means re-deriving how two independent version sequences are kept apart. That is the tool-independence claim's only load-bearing use, and pretending otherwise would be the kind of small overclaim this document is otherwise trying to avoid. Atlas is the schema-as-state alternative worth knowing about; it is more than this take-home needs. 12 13 Version numbering: timestamped filenames during development (avoids collisions when two branches add a migration), converted to a clean sequential 001/002/003 series with `goose fix` — the subcommand that renumbers timestamped files — **before the migrations have been applied anywhere that persists.** That cutoff is the important half: renumbering after application rewrites history the version table already records, so `goose fix` is a pre-first-application step, not a tidy-up before tagging. Not a decision 04 needs to act on — noted so nobody "fixes" the inconsistency later. 14 15 ## 2. Layout 16 17 ``` 18 migrations/ 19 shared/ # applied to every backend, in order 20 postgres/ # Postgres + CockroachDB only 21 sqlite/ # currently empty, kept so its emptiness is deliberate rather than forgotten 22 ``` 23 24 `shared/` carries `auth_method`, `user_profile`, `user_credential`, **`deletion_log`** (required by the retention mechanism in `./pii-governance.md`), and the `auth_method` seed row. `postgres/` currently holds exactly one file — the `pg_trgm` trigram index on `user_profile`'s name column. (`pg_trgm` is a PostgreSQL extension that indexes three-character fragments of text so that substring searches like `LIKE '%smith%'` can use an index instead of scanning every row — it is what makes name search fast. CockroachDB has the same capability built in; SQLite has no equivalent, so the index is omitted there rather than faked.). That parity gap is documented and deliberate (`./multi-db-strategy.md`); SQLite is the local/dev/demo backend, not a production peer. 25 26 **Mechanism 04 needs:** goose applies one directory per invocation, so a migration run is **two invocations** — `shared/` then the backend directory — and each needs its own version table (`goose -table goose_db_version_shared`, then `-table goose_db_version_<driver>`). One shared table across two directories would interleave version numbers from independent sequences and corrupt the history. Both invocations must succeed for the run to count as successful. 27 28 **One open item, flagged rather than buried:** whether a single `shared/` DDL series survives all three backends depends on type names (`UUID`, `BOOLEAN`, `TIMESTAMPTZ`) passing through SQLite's type-affinity rules without surprises — SQLite does not enforce column types the way PostgreSQL does: it assigns each column a loose "type affinity" — a preference for how to store values — and falls back to NUMERIC affinity for any type name it does not recognise, `UUID` included, which is benign for text UUIDs but is exactly the kind of thing that is benign until it isn't. This is under review in story S1 — the DAO contract deliverable — in `./PLAN.md`. **If the review forces a split, the fallback is per-backend `CREATE TABLE` files and `shared/` reduced to seed data — the three-directory layout, the two-invocation mechanism, and everything in §3–§6 are unchanged.** 04 can build against this now; the open item cannot invalidate the infra work. 29 30 ## 3. Requirements on 04 — the part I actually need decided 31 32 **R1. Migrations run as a separate step that completes before any service replica starts.** Not on service boot. Two replicas booting together would both attempt to migrate, and see R2 for why the database will not save you from that. A dedicated job/init step that must exit zero before the service rolls. 33 34 **R2. Exactly one migration runner at a time, enforced by the platform — not by the database.** This is the requirement I most want 04 to read carefully, because the obvious mechanism does not work here. An *advisory lock* is a named lock an application asks the database to hold on its behalf; the database enforces nothing about what it protects, so it is purely a convention between cooperating clients — the usual way to stop two copies of a job running at once. `pg_advisory_lock` is the standard answer on PostgreSQL, but on CockroachDB **session-scoped advisory locks error out, and the `pg_try_advisory_lock` variants silently no-op** — a try-lock returns as though it succeeded and lets a second runner proceed (sourced in `./research-cockroachdb-postgres-semantics.md` §5; only transaction-scoped `pg_advisory_xact_lock` works there). On SQLite the concept is meaningless. A silent false success is a worse failure than an error. **The reason this argument originally gave was wrong, and is replaced.** The first version reasoned "a locking strategy that only works on one backend is not a strategy." That was true of a premise since corrected: `pg_advisory_xact_lock` *does* work on CockroachDB, and SQLite is a single-file local backend where concurrent migration runners are not a scenario that exists — so a transaction-scoped lock works on both backends that can actually have replicas. An objection turn caught that I had over-rotated one research finding into a platform requirement. 35 36 **The conclusion survives on a different and better reason: a transaction-scoped lock cannot cover a migration run.** `pg_advisory_xact_lock` is released when its transaction ends, and goose applies each migration file in its own transaction. The lock would therefore be released *between files*, letting a second runner interleave mid-run — which is precisely the failure it was supposed to prevent. Session-scoped locks, which would span the whole run, are the ones CockroachDB does not support. So the database can offer either the right scope or the right engine coverage, never both. 37 38 So mutual exclusion has to come from the orchestration layer — a job with parallelism 1, a pipeline stage that cannot run concurrently with itself, whatever 04's platform offers. **This is a genuinely open question handed to 04, not a resolved item written up for information.** If 04 concludes the platform cannot guarantee single-runner execution, tell me and I will treat it as a real constraint on the design rather than a detail. 39 40 **A cheap portable backstop worth adding regardless — and an honest gap.** goose's version table is the one artifact all three backends share, so a unique constraint on its `version_id` would make a second concurrent runner fail on insert: portable, free, no platform feature required. **Whether goose's default version table already carries that constraint is unverified here** — I am not asserting it either way, because the objection that raised this correctly pointed out that the document claimed the database could not help without examining the one database-level mechanism goose already creates. The actionable form does not depend on the answer: **add a unique index on `version_id` ourselves in the first migration**, which is portable across all three backends and costs nothing if goose already does it. This is a backstop, not a replacement for R2 — it turns a silent interleave into a loud failure, which is the property actually worth buying. 41 42 **R3. The service fails closed when the schema is behind — checking BOTH version tables.** §2 creates two independent version sequences (`shared/` and the backend directory), so "the applied version" is two numbers, not one. The check must compare both, and must know which backend directory applies to the engine it is connected to. Checking only `shared/` would let a service pass with the `pg_trgm` index unapplied — caught by an objection turn, and it would have been a passing check that proved nothing. On startup the service compares both applied versions against the versions it was built against and refuses to serve if either is behind, rather than serving traffic against a schema missing a column it will query. The requirement is mine; whether the expected version is embedded at build time or derived from the migration files shipped in the image is 03's and 04's call. 43 44 **R4. Two distinct database credentials, not one.** 45 - *Migration runner*: DDL rights (`CREATE`/`ALTER`/`DROP`, index creation) plus, on PostgreSQL only, whatever `CREATE EXTENSION pg_trgm` requires — on managed Postgres this is usually an allow-listed extension, otherwise it needs elevated privileges or the platform pre-installing it. CockroachDB needs nothing here; trigram indexes are native. SQLite has no privilege model at all. 46 - *Runtime service user*: DML only (`SELECT`/`INSERT`/`UPDATE`/`DELETE`). **No DDL.** A running service has no legitimate reason to alter the schema, and a service account that can `DROP TABLE user_profile` turns a SQL-injection finding into data destruction rather than data disclosure. 47 48 Both credentials belong on 02's secrets inventory; I am flagging the requirement, not claiming that surface. 49 50 **R5. Rollback is not the recovery plan for data.** Down migrations will exist for the base schema and are genuinely useful in development. They are not a recovery mechanism in production: reversing a migration that dropped a column does not bring the data back, and the data in question here is PII. Recovery from a destructive migration is backup-restore, and backup policy is 04's. I raise it because "we have down migrations" reads like a safety net and is not one. 51 52 ## 4. Seed data 53 54 The `auth_method` table is seeded with one row, `"password"`. It ships as a migration file in `shared/`, not as a runtime bootstrap in the service — a service that writes its own lookup rows on boot is a service that can write them wrongly, concurrently, on every replica. 55 56 Idempotent via `INSERT ... ON CONFLICT (name) DO NOTHING`, which the unique constraint on `auth_method.name` already supports and which all three backends accept (the same portable upsert form the DAO uses — see `./multi-db-strategy.md`). goose will not re-run an applied migration anyway; the `ON CONFLICT` makes the file safe against a database someone seeded by hand, which in practice is how this breaks. 57 58 New auth methods after `"password"` — `passkey`, `otp`, `oidc` — are **inserted as data, not added as migrations**. That is the entire point of the lookup-table design over a native enum: adding a supported method is an operational action, not a schema change and not a deploy. If 04 finds itself asked to write a migration to add an auth method, that is a signal something has been misunderstood upstream — flag it to me. 59 60 ## 5. Explicitly not owned by this document 61 62 Named so 04 does not read a boundary into this doc that isn't there: where the databases actually run; how the pipeline invokes goose; environment topology and how many of them there are; backup schedule and retention of backups; connection pooling; monitoring of migration runs. All 04's. This document says *what must be true* about migration execution and *why*, and stops there. 63 64 Separately not mine: the **retention sweep and deletion job** are governance mechanisms, not schema migrations, and they are specified in `./pii-governance.md` (policy shape) — not here. Do not implement retention as a migration. 65 66 ## 6. The abstraction ruling — now closed, and it changes nothing here 67 68 Story S3 in `./PLAN.md` — the ruling on the multi-database abstraction shape — **is now ruled**: per-driver implementations behind one interface, confirmed. Full reasoning in `./decisions/multi-db-abstraction.md`. Codegen (sqlc) turned out to be orthogonal to the question rather than a competing option, and is left to 03 as an implementation preference. 69 70 This section originally said "nothing in this document is waiting on S3," and that held: sqlc generates Go from SQL and does not manage schema versions, so the layout, the two-invocation mechanism, R1–R5 and the seed-data rule were never exposed to the outcome either way. **Nothing in this document changed when S3 closed.** It is recorded rather than deleted so 04 can see that the dependency was correctly assessed rather than quietly dropped. 71 72 One thing did change, in R2, and it came from research rather than from S3 — see the advisory-lock correction there. 73 74 --- 75 76 *AI tooling note: drafted by Claude Opus 5 in the 05-data-ops track-lead session, from this track's own research doc and strategy doc, then put through a single devil's-advocate objection turn (Beatriz Achterberg, Spreadsheet archetype, sonnet) before hand-off. Per `../CASTING.md` §5, the objection and its disposition are recorded in `./decisions/`.* 77
{
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/containerization-design.md"
}1 # Containerization Design
2
3 Design-only. No Dockerfile, manifest, or config file is being committed yet — per root `CLAUDE.md`, code/config artifacts wait for Warren's project-wide go-ahead. This document describes what would be built, in enough detail that building it is a transcription exercise, not a design exercise.
4
5 Inputs this depends on: `../PLANNING.md` Service boundaries (03, stable), `../02-ai-security-architecture/handoff-04-secrets.md` v2 (02, stable), `../05-data-ops/migration-approach.md` (05, **final**).
6
7 Deployment target: the real Kubernetes dev/lab cluster on Warren's LAN, operated by amber-kubernetes (an external agent, not contacted during this take-home — see `./PLAN.md`). Manifests below are sketched against that cluster's shape, not a generic "some Kubernetes somewhere."
8
9 ## 1. Two images, not one
10
11 Mirrors 03's two-binary split: `cmd/api-service` and `cmd/idp-connector` are two separate images with independent Dockerfiles, tags, and Deployments. Reasons this isn't one image with two entrypoints: different threat models (per 02 — `api-service` is client-facing with bearer-token auth, `idp-connector` is outbound-only to vendors), different scaling needs, and a vulnerability in one image's dependency tree doesn't force a rebuild/redeploy of the other.
12
13 ## 2. Dockerfile shape (both images, same pattern)
14
15 Multi-stage Go build, three stages:
16
17 1. **`builder`** — `golang:1.2x-alpine` (or `-bookworm` if cgo is needed by a driver), `go mod download` cached as its own layer before `COPY . .`, then `CGO_ENABLED=0 go build -o /out/<binary> ./cmd/<binary>`. Static binary, no libc dependency to drag into the final image.
18 2. **`(none)`** — no separate test stage in the image build itself; tests run in CI (see `./ci-pipeline.md`), not as a Docker build step. A test failure should fail the pipeline stage, not silently produce a passing image build with a skipped test.
19 3. **`final`** — `gcr.io/distroless/static` (or `scratch` if no CA certs are needed — `idp-connector` needs them for outbound TLS to vendors, `api-service` doesn't unless its DB driver requires them). Non-root user (distroless's default `nonroot:nonroot`, UID 65532). Only the compiled binary and, for `idp-connector`, `/etc/ssl/certs` get copied in. No shell, no package manager, nothing an attacker can `exec` into even with a foothold.
20
21 Both images run as a read-only root filesystem (`securityContext.readOnlyRootFilesystem: true` at the Pod spec level) — neither binary writes to disk at runtime; migrations are a separate job (see §4), not something either service does to itself on boot.
22
23 ## 3. Deployment/Service manifest sketch — `api-service`
24
25 ```yaml
26 apiVersion: apps/v1
27 kind: Deployment
28 metadata:
29 name: api-service
30 namespace: loginid-poc # amber-kubernetes' convention, confirmed at apply time — not queried during this take-home
31 spec:
32 replicas: 2
33 selector:
34 matchLabels: {app: api-service}
35 template:
36 metadata:
37 labels: {app: api-service}
38 spec:
39 serviceAccountName: api-service # scoped per handoff-04-secrets.md row #2 — this SA's Role can `get` the JWT signing key only from the issuing code path, not the verification path
40 securityContext:
41 runAsNonRoot: true
42 readOnlyRootFilesystem: true
43 containers:
44 - name: api-service
45 image: registry.internal/loginid-poc/api-service:<tag>
46 resources:
47 requests: {cpu: "100m", memory: "128Mi"}
48 limits: {cpu: "500m", memory: "256Mi"}
49 envFrom:
50 - configMapRef: {name: api-service-config} # DB_DRIVER, HTTP_ADDR, AUTH_JWT_ISSUER/AUDIENCE — non-secret
51 env:
52 - {name: DB_DSN_FILE, value: /secrets/db/dsn}
53 volumeMounts:
54 - {name: db-dsn, mountPath: /secrets/db, readOnly: true}
55 - {name: jwt-signing-key, mountPath: /secrets/jwt, readOnly: true}
56 readinessProbe: {httpGet: {path: /healthz, port: 8080}, initialDelaySeconds: 5}
57 livenessProbe: {httpGet: {path: /healthz, port: 8080}, initialDelaySeconds: 10}
58 volumes:
59 - name: db-dsn
60 secret: {secretName: api-service-db-dsn}
61 - name: jwt-signing-key
62 secret: {secretName: api-service-jwt-signing-key}
63 ---
64 apiVersion: v1
65 kind: Service
66 metadata: {name: api-service}
67 spec:
68 selector: {app: api-service}
69 ports: [{port: 443, targetPort: 8080}]
70 ```
71
72 `idp-connector`'s manifest is the same shape with its own two `Secret` volume mounts (per-vendor client credentials, one `Secret` per vendor per handoff row #5), its own `resources` block (`idp-connector` is I/O-bound waiting on vendor calls, not CPU-heavy — requests `{cpu: 50m, memory: 64Mi}`, limits `{cpu: 250m, memory: 128Mi}`), and no inbound `Service` — it is called by `api-service` or invoked as a job, never exposed to anything outside the cluster. Exact invocation shape (long-running service vs. on-demand job) is undecided; either way the container/image design above doesn't change.
73
74 **On process vs. host isolation (per 03/Renata, `../PLANNING.md`):** the two-binary split is a process boundary, not a placement guarantee — the `resources.limits` above are what actually enforce it if both Pods land on the same node, on top of (not instead of) 03's own connector-side backpressure (`context.WithTimeout`, bounded retry/backoff, their S4).
75
76 ## 4. Migration job
77
78 Per 05's requirement R1/R2 (`../05-data-ops/migration-approach.md`, **final**): a `Job`, not an init container, with `parallelism: 1` and `backoffLimit` set so it doesn't retry indefinitely against a database it's already partially migrated. Runs `goose` against the migration-runner credential (DDL-only, distinct from the runtime service credential per R4 — and, on PostgreSQL specifically, needing `CREATE EXTENSION` rights for `pg_trgm` that the runtime credential must never hold, per `./secrets-delivery.md`) before the Deployment's rollout proceeds — enforced by a CI/CD pipeline gate (`./ci-pipeline.md` §4), not by Kubernetes ordering alone, because R2's single-runner requirement has to hold across concurrent deploys too, not just within one.
79
80 R2's mechanism is orchestration-layer exclusivity (a concurrency-gated pipeline stage, `./ci-pipeline.md` §4), not a database lock — final reasoning: a transaction-scoped lock (`pg_advisory_xact_lock`, CockroachDB-compatible) releases between `goose`'s per-file transactions, so a second runner could interleave mid-run even with one held; a session-scoped lock would span the whole run but CockroachDB doesn't support that scope. No single database mechanism covers both the right scope and the right engine, so the exclusivity has to live outside the database entirely, on every backend, not just CockroachDB. 05 also names a cheap backstop — a unique constraint on `goose`'s version-table `version_id` column, added in the first migration regardless of whether goose's default schema already has one — so a second runner that does slip through fails loudly on insert instead of interleaving silently. That's a migration-file change (03/05's to make), not something 04 builds, but it's worth naming here since it's the backstop behind this job's own exclusivity design, not a replacement for it.
81
82 **Fail-closed schema check (R3, final):** the readiness probe below isn't just a liveness ping — `api-service` must refuse to serve if either of goose's two version tables (`shared/` and its backend-specific directory, per `migration-approach.md` §2's two-invocation mechanism) is behind the version the binary was built against. A check against only one table can pass while the other is stale — e.g. `shared/` current but the Postgres-only `pg_trgm` index migration unapplied — which is a passing check that proves nothing. `/healthz` above is liveness only; a separate `/readyz` (or the same endpoint gated by both checks) is where this lives, wired to whatever version-embedding mechanism 03 picks (build-time constant vs. reading the shipped migration files) — the check comparing *both* tables is 04's requirement to build against; the embedding mechanism is 03's call.
83
84 ## 5. NetworkPolicy: adopted, not cut
85
86 Considered and dropped from an earlier pass as scope creep, then reconsidered: §3's claim that `idp-connector` is isolated because it never gets an inbound `Service` is a paper boundary without one. Anything else on the namespace's default-allow network can still reach it directly and skip `api-service`'s auth path entirely — which undermines the exact "different threat models" reasoning 02 gave for splitting the images in the first place. A minimal default-deny `NetworkPolicy` for the `idp-connector` Pod, with one explicit `ingress` rule allowing traffic only from `api-service`'s pod selector, is ~15 lines and one more manifest to keep in sync — cheap relative to what it closes. Included below; everything else in this section stays cut.
87
88 ```yaml
89 apiVersion: networking.k8s.io/v1
90 kind: NetworkPolicy
91 metadata: {name: idp-connector-default-deny}
92 spec:
93 podSelector: {matchLabels: {app: idp-connector}}
94 policyTypes: [Ingress]
95 ingress:
96 - from: [{podSelector: {matchLabels: {app: api-service}}}]
97 ```
98
99 ## 6. What's deliberately not here
100
101 No service mesh, no Istio/Linkerd sidecar, no HorizontalPodAutoscaler, no PodDisruptionBudget. This is a two-service take-home submission, not a platform. If any of these earn their place later, they're additive to this design, not a rewrite of it.
102
103 ---
104 *AI tooling note: drafted directly by Theo (Sonnet, this session) from 03/02/05's hand-offs. Bree's and Callum's per-deliverable notes are pending — folded in per `./PLAN.md` §3 once their turns land, rather than as a separate decision artifact.*
105 {
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/ci-pipeline.md"
}1 # CI/CD Pipeline Design 2 3 Design-only — a description of what would run, not a committed workflow file. Targets a generic CI platform (GitHub Actions shape, since the repo is on GitHub) but nothing here is Actions-specific beyond syntax. 4 5 ## Stages, in order, on every PR 6 7 1. **Build.** `go build ./...` for both `cmd/api-service` and `cmd/idp-connector`, against the module's declared Go version. Fails fast — nothing downstream runs against code that doesn't compile. 8 2. **Lint.** `golangci-lint run` (staticcheck, govet, unused, errcheck at minimum). Non-negotiable gate, not advisory — a lint failure blocks merge, same as a test failure. 9 3. **Test.** `go test ./... -race` — unit tests against 03's `internal/dao` per-backend implementations: SQLite in-process for the fast path, plus **two** testcontainer jobs against the shared `postgres` package — one on Postgres, one on CockroachDB. Considered running CockroachDB only in the local dev-loop (wire-compatible enough with Postgres that testing both looked redundant) and reconsidered for CI specifically: wire-compatible isn't schema-compatible, and a Postgres-only test matrix means a CockroachDB-specific SQL quirk (UUID defaults, upsert syntax, lock semantics) surfaces at deploy time instead of at PR time. CockroachDB ships an official single-node Docker image, so this is a config addition to the test job, not new infrastructure — a few extra minutes of CI wall-clock for catching it at the cheapest possible point. `-race` is not optional given two DB implementations and a migration-runner exclusivity requirement (05's R2) that's exactly the kind of thing a race detector catches in test even though it's enforced by orchestration in production. 10 4. **Security scan.** Two checks, both fast enough to run on every PR: 11 - `govulncheck ./...` — known-CVE dependency scan. 12 - `gitleaks` (or equivalent secret-pattern scanner) over the diff — a second line of defense behind "secrets never enter source" (per 02's never-log/never-commit discipline), not a replacement for it. 13 5. **Build images.** Both Dockerfiles (`./containerization-design.md` §2) built and tagged with the commit SHA. Not pushed yet on a PR build — build-only, to catch a broken multi-stage build before merge. 14 15 ## On merge to main 16 17 6. **Push images** to the registry, tagged with SHA and `latest` (or a semver tag if the project adopts one — not decided, not needed for a take-home). 18 7. **Run migration job** — describe-only in this design, per `../05-data-ops/migration-approach.md`, **final**: the pipeline invokes `goose` against the migration-runner credential, gated so only one pipeline run at a time can reach this stage. Final reasoning (05's R2, corrected from an earlier draft): CockroachDB's `pg_try_advisory_lock` variants silently no-op rather than erroring, but even a working lock scope doesn't solve this — a transaction-scoped lock (the only kind CockroachDB supports) releases between `goose`'s per-file transactions, letting a second runner interleave mid-run, while a session-scoped lock that would span the run isn't available on CockroachDB at all. No database mechanism covers both the right scope and the right engine, so exclusivity lives entirely outside the database: a concurrency group / mutex on the pipeline's own deploy environment. Note `goose fix` (§1 of the migration doc) is a pre-first-application step, not a pre-tag tidy-up — irrelevant to this pipeline's own stages but worth remembering if a `make` target ever wraps it. 19 8. **Deploy — describe-only, not executed.** A named final stage: "apply manifests to the lab cluster via amber-kubernetes." This step is documented as what *would* run — which manifests, which namespace, which credential applies them — but is never actually invoked during this take-home, consistent with the deployment-target note in `./PLAN.md`. If a real deploy is ever wanted, this is the stage that gets un-stubbed, not redesigned. 20 21 ## What's deliberately not in this pipeline 22 23 No canary/blue-green deploy strategy, no multi-environment promotion pipeline (dev → staging → prod), no automated rollback orchestration beyond "the migration job's `backoffLimit` and the Deployment's own rollout history." A take-home doesn't need a release engineering platform; it needs to show the shape of one. 24 25 --- 26 *AI tooling note: drafted directly by Theo (Sonnet, this session) from 03's test-strategy outline and the containerization design above. Bree's (capability-case) and Callum's (scope-cut) per-deliverable notes reviewed and folded in inline above, per PLAN.md §3.* 27
{
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/secrets-delivery.md"
}1 # Secrets Delivery Mechanics 2 3 Design-only. This document implements the delivery mechanism for what `../02-ai-security-architecture/handoff-04-secrets.md` v2 specifies must be a secret and why — this track doesn't decide what's a secret, only how it reaches a running process. Row numbers below refer to that hand-off's inventory table. 4 5 ## Mechanism: Kubernetes-native `Secret` objects, file-mounted 6 7 Per the hand-off's Kubernetes-specific requirements: every secret in the inventory is delivered as a `Secret` resource, mounted read-only as a file into the consuming Pod, never passed as a plaintext env var except the one named transitional exception below. Manifests reference `Secret` **names** only (`secretName: api-service-db-dsn`) — values are never in a manifest, never in source, never in an image layer. Values are provisioned out-of-band by whoever applies the manifest (amber-kubernetes, per the deployment-target note) — this take-home describes that step, doesn't perform it. 8 9 | Row | Secret | Mount | Consuming ServiceAccount / RBAC | 10 |---|---|---|---| 11 | #1 | `DB_DSN` | File, read via `DB_DSN_FILE` (03's convention, per the hand-off's upgraded requirement). Env-var `DB_DSN` supported only as a fallback until 03 ships the `_FILE` convention. | `api-service` SA | 12 | #2 | JWT signing private key | File only, never env | Restricted Role scoped to the issuing code path within `api-service`'s own SA — not the verification path, per the hand-off's assumption 2 | 13 | #3 | JWT public key / JWKS | Not a `Secret` — `ConfigMap` or fetched URL | n/a | 14 | #4 | API caller `client_secret`s | Not a Kubernetes `Secret` — lives in the authorization server's own datastore (hashed, Argon2id) | Datastore credentials are shaped like row #1, delivered the same way | 15 | #5 | `IDP_ABC_CLIENT_ID`/`SECRET` (+ XYZ pair) | File, one `Secret` per vendor so rotating one never touches the other | `idp-connector` SA | 16 | #6 | Vendor `access_token` | Not deployed — transient, in-memory only. If cached, encrypted with #7's KEK; no `Secret` object | n/a | 17 | #7 | KEK for envelope encryption | File. Per the hand-off's resolved v2: not a live requirement until TOTP or token-caching actually materializes — provisioned only if/when 05 or 02 confirms one of those features is real. If a KMS is used instead of a mounted key (row #7a), its access credential follows the same delivery discipline as row #2. | `api-service` and/or `idp-connector` SA, whichever feature lands | 18 | #8 | TLS serving private keys | `kubernetes.io/tls` `Secret`, automated issuance preferred (cert-manager or equivalent) | Ingress, or each binary if TLS terminates at the pod | 19 | #9 | End-user vendor password | Nothing to deliver — transient request-body data, never persisted | n/a | 20 | — | Migration-runner DB credential (05's R4, not in 02's inventory — flagged there as belonging to this surface) | File, same discipline as row #1. **Provisioning differs by backend:** on PostgreSQL specifically this credential needs `CREATE EXTENSION` rights for `pg_trgm` (the trigram index behind `user_profile.name` search) — usually an allow-listed extension on managed Postgres, otherwise elevated privilege; CockroachDB needs nothing extra (trigram indexing is native); SQLite has no privilege model at all. Distinct from the runtime service credential (DML-only, no DDL) per R4 — provisioning one shared credential for both would hand the running service the same extension-install rights the migration job needs, which it has no reason to hold. | Migration `Job`'s own ServiceAccount only | 21 22 ## RBAC discipline 23 24 No ServiceAccount `get`s or `list`s a `Secret` it doesn't mount — enforced by a `Role`/`RoleBinding` per service, scoped to exactly the `Secret` names in the table above for that service, not a blanket namespace-read grant. Per the hand-off, this is narrower still for row #2: `api-service`'s own SA needs a distinct Role restricting the signing-key `Secret` to the issuing code path, which in practice means either a second SA for the issuer mode or a sidecar/init-container pattern that reads the key and the main container process never does — the exact split is an implementation detail for 03, not a change to this delivery mechanism. 25 26 The audit/access-log read requirement (hand-off, Kubernetes-specific requirements, last bullet) is a **separate** RBAC grant from Secret access — see `./observability.md` for how that log stream is isolated. 27 28 ## Secrets-at-rest in etcd 29 30 Per the hand-off's explicit either/or: this design's answer is **enable encryption at rest for the `Secret` resource type** at the cluster level (an `EncryptionConfiguration` with a KMS or `aescbc` provider), rather than sourcing every value from an external store. Reasoning: an external store (Vault, cloud secrets manager) is real infrastructure this lab cluster doesn't currently run, and standing one up is out of scope for a take-home submission whose deployment is design-only anyway; cluster-level encryption-at-rest is a one-time `kube-apiserver` flag, not a new service to operate. If this became a real deployment with an existing Vault install, that would be the better answer — named here as the alternative, not designed. 31 32 ## CI-side secret handling 33 34 No secret in an image layer, build arg, or CI log — enforced by the pipeline's own gitleaks stage (`./ci-pipeline.md` §4) plus the container design never `COPY`ing anything but the compiled binary. CI's own credentials to reach the registry and (in the describe-only deploy stage) the cluster are themselves managed as CI-platform secrets (GitHub Actions encrypted secrets or equivalent) — same discipline, different delivery surface, not re-specified here since it's a standard CI-platform mechanism rather than something this design invents. 35 36 --- 37 *AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2. Bree's (capability-case) and Callum's (scope-cut) per-deliverable notes reviewed and folded in inline above, per PLAN.md §3.* 38
{
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/local-dev-loop.md"
}1 # Local Dev-Loop
2
3 Design-only — a docker-compose sketch, not a committed `docker-compose.yml`. Local counterpart to the cluster target in `./containerization-design.md`, not a replacement for it: this is what a developer runs on a laptop; the cluster design is what the submission targets as the real deployment shape.
4
5 ## Two compose profiles, matching 03's `DB_DRIVER` env var
6
7 ```yaml
8 # docker-compose.yml (sketch)
9 services:
10 api-service:
11 build: {context: ., dockerfile: cmd/api-service/Dockerfile}
12 environment:
13 DB_DRIVER: ${DB_DRIVER:-sqlite}
14 DB_DSN: ${DB_DSN:-file:/data/dev.db}
15 HTTP_ADDR: ":8080"
16 ports: ["8080:8080"]
17 volumes: ["sqlite-data:/data"]
18 profiles: ["sqlite"]
19
20 api-service-pg:
21 extends: {service: api-service}
22 environment:
23 DB_DRIVER: postgres
24 DB_DSN: postgres://dev:«REDACTED»@postgres:5432/loginid_dev?sslmode=disable
25 depends_on: [postgres]
26 profiles: ["postgres"]
27
28 postgres:
29 image: postgres:16-alpine
30 environment: {POSTGRES_USER: dev, POSTGRES_PASSWORD: dev, POSTGRES_DB: loginid_dev}
31 ports: ["5432:5432"]
32 profiles: ["postgres"]
33
34 idp-connector:
35 build: {context: ., dockerfile: cmd/idp-connector/Dockerfile}
36 environment:
37 IDP_ABC_BASE_URL: ${IDP_ABC_BASE_URL:-http://mock-idp:9090}
38 IDP_ABC_CLIENT_ID: dev-client
39 IDP_ABC_CLIENT_SECRET: dev-secret
40 profiles: ["sqlite", "postgres"]
41
42 volumes:
43 sqlite-data:
44 ```
45
46 `docker compose --profile sqlite up` for the zero-dependency path (no Postgres container, fastest inner loop); `docker compose --profile postgres up` when a developer needs to exercise the Postgres-specific code path (the `pg_trgm` index, per `../05-data-ops/multi-db-strategy.md`). CockroachDB isn't in the local loop — it's wire-compatible enough with Postgres per 05's strategy doc that the Postgres profile exercises the shared code path; a CockroachDB-specific compose profile would test the same code again, not new code.
47
48 ## Dev credentials are dev credentials
49
50 The `dev`/`dev` Postgres password and the hardcoded `dev-client`/`dev-secret` above are local-only, never used outside a developer's own machine, and never the pattern used in `./containerization-design.md` or `./secrets-delivery.md` — those go through the mechanisms 02 specifies. Worth stating explicitly so nobody reads this file as the security design.
51
52 ## Migrations locally
53
54 `goose up` (or whatever 05 finalizes) run by hand or via a `make migrate` target against whichever profile is active — no orchestration-layer exclusivity needed locally since there's exactly one developer and one instance. The R2 requirement (`../05-data-ops/migration-approach.md`) is a production/CI concern; the local loop doesn't need to simulate it.
55
56 ## Hot-reload for `api-service`, dev-profile only
57
58 Considered cutting `air` as unneeded polish, reconsidered: `docker compose build && up` on every change is a slow inner loop for a project a developer iterates on repeatedly, and the cost of adding it is close to zero — one `air`-based dev target in the compose file, no production image impact since it only applies to the `sqlite`/`postgres` dev profiles, never `./containerization-design.md`'s images. Worth doing, not worth debating further.
59
60 ## What's deliberately not here
61
62 No local Kubernetes (`kind`/`minikube`) mirroring the lab cluster — compose is the right altitude for a two-service Go project's local loop; a local k8s cluster would be solving a problem this project doesn't have.
63
64 ---
65 *AI tooling note: drafted directly by Theo (Sonnet, this session) from 03's config surface and the containerization design above. Bree's (capability-case) and Callum's (scope-cut) per-deliverable notes reviewed and folded in inline above, per PLAN.md §3.*
66 {
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/threat-model.md"
}1 # Threat Model 2 3 Owner: Marcus Ilori (`02-ai-security-architecture`). Scope per this track's `CLAUDE.md`: credential storage, PII exposure, and third-party `access_token` handling. Retention/lifecycle policy is `../05-data-ops/CLAUDE.md`'s call, not mine — I model exposure and access risk, not how long data should live. 4 5 ## Assumptions, named explicitly 6 7 1. **Assets in scope**: `user_credential` (username, method, password — modeled by Priya/`05-data-ops` as a lookup-table `method`, not a native enum, so a given user can have more than one credential row, e.g. password + WebAuthn), `user_profile` (name, address, phone), and third-party `access_token`s obtained via the IDP connector's `/auth`. 8 2. **`method` is not "always password."** Priya's schema treats `method` as an FK into an `auth_method` table (`id`, `name`, `requires_secret`, `is_active`). That means "credential storage" is not one hashing problem — it's a family of them, one per method, and this document treats it that way rather than assuming a single password column. 9 10 ### Concrete hashing decision, against the landed schema 11 12 Priya's prose schema in `../05-data-ops/multi-db-strategy.md` (the `.sql` files were deliberately wiped with the rest of the code on 2026-09-12; the prose contract is the source of truth until Warren's code go-ahead) gives `user_credential` three columns for this purpose: `secret` (nullable TEXT, salted hash only — NULL where `auth_method.requires_secret = false`, e.g. passkey/WebAuthn), `hash_algo` (VARCHAR, e.g. `'argon2id'`), and `hash_cost` (INTEGER, the work factor at hash time). This track's concrete decision against those columns: 13 14 - **`hash_algo = 'argon2id'`**, per OWASP's current recommendation, consistent with NIST SP 800-63B Rev. 3's requirement of a memory-hard one-way key derivation function (800-63B Rev. 3, currently in force, requires a "suitable" memory-hard KDF without naming Argon2id specifically by name; Argon2id-by-name guidance appears in the SP 800-63-4 draft revision, not yet final — cited precisely rather than invoking "NIST recommends Argon2id" as if it were settled text). bcrypt is the documented fallback (`hash_algo = 'bcrypt'`) only if the Go implementation's Argon2id library support is unavailable in the chosen stack — a call for `03-engineering-delivery` to confirm, not a reason to default to bcrypt speculatively. 15 - **Salt handling**: no separate salt column is needed or wanted. Argon2id's standard encoded output (the `«REDACTED-ARGON2»` PHC string format) embeds a fresh random salt per row inside the `secret` column's own text — this is what makes the hash self-describing and rehashable without a side lookup. Do not add a `salt` column; it would be redundant with the encoded format and an easy place for an implementation to accidentally desync salt from hash. 16 - **`hash_cost` at minimum**: memory ≥19 MiB, iterations ≥2, parallelism ≥1 (OWASP's Argon2id floor) — recorded per-row (not just globally) specifically so a future cost bump can be applied to newly-created/rotated rows without forcing every existing row to rehash in lockstep, per the schema's own stated intent of supporting an in-place scheme upgrade. 17 - **`requires_secret = false` rows** (passkey/WebAuthn, `secret IS NULL`): no hashing question at all — the server holds only public key material for those methods, which is a different asset class (public, not secret) and does not belong in `secret`/`hash_algo`/`hash_cost` at all if/when a passkey method is actually added. Flagged here so `03-engineering-delivery` doesn't try to force a WebAuthn public key through the password-hashing path. 18 3. **LoginID's own business is passwordless/FIDO2-based authentication.** The assignment nonetheless asks for a `password` field in `user_credential`. I treat that as a deliberate test of whether the submitter still applies correct secret-handling discipline to a legacy-shaped requirement, not as license to under-engineer it. Noted once here; not re-litigated per section. 19 4. **No real infrastructure exists yet.** This is a design-time threat model for a take-home, not an audit of a running system. Findings are stated as requirements the engineering track (`../03-engineering-delivery/CLAUDE.md`) must implement, not as "confirmed" vulnerabilities. 20 5. **Trust boundary**: this service is the system of record for `user_profile`/`user_credential` and is a *client* of third-party IDPs (ABC/XYZ) for `/auth` and `/identity`. Those vendors are outside our control; we can only constrain what we do with what they return. 21 6. **Trust boundary — deployment operator.** The design-only deployment target (`../04-infra-devops/`) is a Kubernetes lab cluster whose manifests would be applied by an external operator agent. Whoever can apply a manifest can read any Secret it mounts, so "secrets are safe once they are in the cluster" is an assumption about that operator, not a property of the design. Consequence, set as a requirement in `handoff-04-secrets.md`: manifests carry Secret names, never values; values are provisioned out-of-band; the operator's rights are documented, not hidden. Nothing is deployed and the operator is never contacted during this take-home. 22 23 ## Method: STRIDE per asset, attacker-centric 24 25 I'm using STRIDE (Microsoft, but the categories are now common vocabulary in the field — not claiming Anthropic or LoginID provenance for the framework itself) as the checklist, but organized by asset rather than by data flow diagram element, since that maps directly onto this track's ownership boundaries. 26 27 ### Asset 1: `user_credential` (password + other auth methods) 28 29 | Threat | Scenario | Mitigation (this track's requirement) | 30 |---|---|---| 31 | **Information disclosure** — password store compromise | DB dump or SQL injection exposes the `password` column | Never store plaintext or reversibly-encrypted passwords. Argon2id (OWASP Password Storage Cheat Sheet current recommendation, memory-hard, GPU-resistant) with per-row random salt, tuned to ≥19 MiB memory / ≥2 iterations as a floor, re-tuned to the deploying hardware. bcrypt is an acceptable fallback only if Argon2id is unavailable in the chosen Go stack. | 32 | **Information disclosure** — non-password methods | A WebAuthn public key or TOTP shared secret is not a password, but the table conflates them under one `password` column name | Method-aware storage: WebAuthn credential public keys are not secrets and can be stored in the clear (they're public keys by design) but the associated counter and credential ID must not be attacker-writable — signature-counter and clone-detection semantics per W3C WebAuthn Level 2 §6.1.1; TOTP shared secrets ARE secrets and must be encrypted at rest (not hashed — the server needs to recover the value to verify), using envelope encryption with a KMS-held key, mechanics owned by `../04-infra-devops/CLAUDE.md`. | 33 | **Elevation of privilege** — credential stuffing / brute force | Attacker replays leaked username/password pairs against `/auth`-adjacent endpoints | Rate limiting and progressive backoff per username and per source IP, preferred over hard account lockout — NIST SP 800-63B §5.2.2 actively disfavors lockout as a self-inflicted denial-of-service vector against the legitimate user, in favor of throttling/backoff/step-up challenge. Never reveal via response timing or error text whether the *username* or the *password* was wrong (user enumeration). | 34 | **Tampering** | Attacker modifies `method` or the credential row to downgrade a user from a strong method (WebAuthn) to a weak one (password) they control | Method changes/additions require re-authentication with an *existing* registered method (step-up), never a bare authenticated-session write. | 35 | **Repudiation** | No record of who created/rotated a credential | Audit log of credential creation/rotation events (actor, timestamp, method type — never the secret itself). | 36 37 ### Asset 2: `user_profile` (name, address, phone — PII) 38 39 | Threat | Scenario | Mitigation | 40 |---|---|---| 41 | **Information disclosure via the search API** | Question 2 asks for "search and retrieve." An authenticated-but-unscoped search endpoint is a PII enumeration engine — an attacker with any valid token can page through the entire user table by name/phone fragments | Search must be authorization-scoped (a caller sees only records it's entitled to, not the whole table) and rate-limited/paginated; see `api-auth-design.md` for the concrete authz model. This is the single highest-severity finding in this document, precisely because it's easy to build the search feature "to spec" and miss that spec implies a data-exfiltration primitive. | 42 | **Information disclosure in transit** | PII served over an unencrypted or downgradable channel | TLS 1.2+ mandatory (1.3 preferred where supported), HSTS, no plaintext fallback, per NIST SP 800-52 Rev. 2's server TLS configuration guidance. Mechanics of certificate issuance are `04-infra-devops`'s call; the requirement is this track's. | 43 | **Information disclosure via logs** | PII (address, phone) written to application logs, error traces, or APM breadcrumbs | Structured logging with an explicit PII-field denylist; log the *fact* of a profile access (who, when, which record id) never the field values themselves. | 44 | **Tampering** | Unauthorized profile field modification | Same authz model as read access; write operations additionally require the resource owner or an admin-scoped credential, not just "any authenticated caller." | 45 46 ### Asset 3: Third-party `access_token` (from the IDP connector, question 3) 47 48 | Threat | Scenario | Mitigation | 49 |---|---|---| 50 | **Information disclosure** — token at rest | `access_token` from ABC/XYZ cached or persisted in plaintext | Treat it as a secret with the same bar as our own credentials: encrypted at rest if persisted at all, short TTL, never written to application logs (a full request/response log of `/auth` is an anti-pattern — vendor password *and* resulting token both land in the same log line). | 51 | **Information disclosure** — token in transit / replay | Token intercepted and replayed against the vendor or against us | Vendor calls over TLS only; tokens treated as bearer secrets — anyone holding one can act as us to the vendor, so token scope should be minimized to `/identity` only if the vendor's API supports scoped tokens. | 52 | **Spoofing** | A malicious or compromised proxy sits between us and ABC/XYZ | Certificate validation not disabled "for convenience" in any environment, including local dev — a design note the engineering track should carry into implementation. | 53 | **Denial of service via credential exhaustion** | Our own credentials to call ABC/XYZ (needed to obtain the `access_token` in the first place) are hardcoded or checked into source | Secrets management is `04-infra-devops`'s mechanism, but the requirement — never in source, never in a committed config file, injected at runtime — is set here. | 54 55 ## What this track deliberately does not resolve 56 57 - How long a cached `/identity` result may be retained — that's data governance (`../05-data-ops/CLAUDE.md`). 58 - Which specific secrets manager or KMS product is used — that's `../04-infra-devops/CLAUDE.md`; I only require that one exists and that secrets never live in source or plaintext config. 59 - Whether the product should offer passwordless auth as the primary path (it should, per LoginID's own market position, and `../01-product-industry-research-design/CLAUDE.md` makes that case) — that's product framing, not a threat. 60 - **This API's own issued JWT bearer access tokens are not re-modeled as a fourth asset here** — they're `api-auth-design.md`'s asset, covered by that document's own token-lifecycle and scope-authorization sections. Named explicitly (S8 review, Helena Marsh) so the boundary between "the three assets this track's `CLAUDE.md` names" and "thorough" reads as a deliberate scope line, not an oversight. 61 62 ## Sources 63 64 - OWASP Password Storage Cheat Sheet (Argon2id as current default recommendation): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html 65 - NIST SP 800-63B Rev. 3, §«IP» (memory-hard KDF requirement for password verifiers) and §5.2.2 (rate-limiting/throttling preferred over lockout for online guessing): https://pages.nist.gov/800-63-3/sp800-63b.html 66 - W3C WebAuthn Level 2, §6.1.1 (signature counter and clone-detection semantics): https://www.w3.org/TR/webauthn-2/#sctn-sign-counter 67 - NIST SP 800-52 Rev. 2 (guidelines for TLS server configuration): https://csrc.nist.gov/pubs/sp/800/52/r2/final 68 - OWASP API Security Top 10 (particularly API1:2023 Broken Object Level Authorization and API3:2023 Broken Object Property Level Authorization — both directly relevant to the search/retrieve endpoint finding above): https://owasp.org/API-Security/editions/2023/en/0x11-t10/ 69 - STRIDE threat modeling categories, Microsoft: https://learn.microsoft.com/en-us/azure/security/develop/threat-modeling-tool-threats 70 71 ## S8 independent review (Helena Marsh) 72 73 Four citation/precision fixes and one completeness note, all accepted: NIST SP 800-63B was invoked by name for Argon2id without a correct citation (soften-and-cite fix above); WebAuthn counter/credential-ID integrity had no source (W3C WebAuthn Level 2 §6.1.1 added); the TLS floor was asserted without a source (NIST SP 800-52 Rev. 2 added); credential-stuffing mitigation presented lockout as a peer option to throttling rather than naming NIST's stated preference against lockout (SP 800-63B §5.2.2 added, lockout language removed); the asset list's silent boundary around this API's own issued tokens is now stated explicitly. Helena did not author or blue-team this document, so this review is independent per `PLAN.md` S8. 74
{
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/industry-framing.md"
}1 # Industry & Competitive Framing
2
3 Prepared for the `01-product-industry-research-design` track. See `../CLAUDE.md` for the full assignment text and `./CLAUDE.md` for this track's scope and boundaries. Builds directly on `research-loginid-company.md` and `research-oauth-history.md` in this directory — read those first for sourcing; this document is the synthesized argument, not new research.
4
5 **AI tooling note:** produced by Claude Code (Sonnet 5) running as a standing track-owner subagent for this track (persona: Naomi Voss), reading this track's own CLAUDE.md as its brief and the two prior research passes as its evidence base. No new web research was done for this document — it is a synthesis pass over material already gathered.
6
7 ## Who is this for
8
9 Two audiences read this document, and the framing below is written to serve both without contradiction:
10
11 1. **LoginID's technical evaluators.** They already know their own market. What they're grading here is whether a candidate can place a deliberately simple assignment (password-based credentials, a bare username/password IDP connector) accurately on the map of where the industry actually is — and say so plainly rather than either quietly building an insecure relic or over-engineering a FIDO2 flow nobody asked for.
12 2. **Whoever would actually consume this system if it were built for real** — an internal team building a profile-search admin tool, or a product team wiring up an onboarding flow that calls out to a third-party IDP connector. This document's persona/use-case section (see `personas-use-cases.md`) exists because "who is this for" has a real answer beyond the interview panel, and that real answer should shape the design.
13
14 ## 1. Where this assignment sits in the identity/IAM landscape
15
16 The three questions asked — a DAO for `user_profile`/`user_credential`, a searchable REST API over that data, and a connector to third-party identity providers using `/auth` (username/password → token) and `/identity` (token → PII) — describe a **classic CIAM (Customer Identity and Access Management) backend**: the boring, load-bearing plumbing that sits underneath every "log in" button. This space is large, mature, and crowded:
17
18 - **Full-platform CIAM**: Okta/Auth0, Microsoft Entra ID, Ping Identity — passwordless is one feature among many (SSO, MFA, lifecycle management, B2B federation).
19 - **Passkey/FIDO2 specialists**: LoginID itself, HYPR, Beyond Identity, Transmit Security, Corbado, Hanko, Descope — narrower, deeper on one authentication method.
20 - **IDP-aggregation/identity-verification vendors**: services whose entire business is "collect PII from a person once and let many relying parties query it" — the same shape as the assignment's `/identity` endpoint, at a bigger scale.
21
22 The assignment sits at the intersection of the first and third categories, but built at the "baseline reference implementation" layer rather than the polished-product layer — which tracks with the assignment's own instructions that code doesn't need to be complete, and that reasoning matters more than a finished product.
23
24 ## 2. The deliberate gap: what LoginID sells vs. what LoginID is asking for
25
26 This is the framing that most needs to be said out loud, because it's easy to miss and, once seen, it reframes the whole submission.
27
28 LoginID's actual product is FIDO2/WebAuthn passkey authentication: asymmetric device-bound credentials unlocked by biometrics, with **no shared secret ever transmitted or stored server-side** (see `research-loginid-company.md` §1). The assignment, by contrast, specifies:
29
30 - `user_credential (username, method, password)` — a shared-secret password field, the exact thing passkeys exist to eliminate.
31 - A third-party `/auth` endpoint authenticating with `{"username", "password"}` — a legacy password-grant pattern, not an OAuth2/OIDC delegated-authorization flow.
32
33 **This is the conventional, industry-standard credential baseline** used across CIAM backends generally, not a signal we're reading as unique to LoginID — a resolved S2 debate concluded that "LoginID chose this gap deliberately" is an unfalsifiable claim about the assignment author's intent, with no artifact in the assignment text to distinguish it from an ordinary take-home template (see `decisions/password-baseline-debate.md`). We don't need that inference to justify what matters here, which is building the baseline correctly regardless of who asked for it:
34
35 - **Name the gap explicitly** rather than silently building an insecure password store as if it were best practice, or silently substituting a FIDO2 flow the assignment didn't ask for (which would answer a different question than the one asked).
36 - **Design the seams so the gap is closeable later without a rewrite.** The `user_credential.method` field is the natural extension point — a value like `"password"` today, `"webauthn"` or `"passkey"` tomorrow, without changing the table shape. The third-party connector's pluggable-provider design (see `api-connector-design-rationale.md`) is the equivalent seam on the IDP side — a modern OIDC/OAuth2 provider should slot in next to the ABC/XYZ password-grant providers without a redesign.
37 - **Still handle what's actually being asked with the rigor the brand implies** — hashed/salted passwords at minimum if a password field must exist at all, least-privilege API auth on search/retrieve, secrets hygiene for stored third-party access tokens. (Full mechanics of this are `../02-ai-security-architecture/CLAUDE.md`'s scope; this document only asserts the product-narrative reason it matters — LoginID's whole brand *is* auth security, so an unhashed password field in the reference implementation, however illustrative, would read as tone-deaf.)
38
39 In short: **the assignment's design is the industry-standard "before" baseline; LoginID's product is one credible "after."** The strongest submission narrative is "here is a solid, correctly-built baseline, with the seams marked for where it could evolve toward passkeys" — stated as forward-looking architecture, not as a claim about what the assignment's author had in mind.
40
41 ## 3. The adjacent frontier: agentic identity, and why it's relevant but out of scope
42
43 Two things are converging on LoginID's roadmap that are each worth naming, but neither should be built into this submission's actual code:
44
45 - **LoginID's own 2025–2026 repositioning** toward "agentic commerce" — authenticating AI agents transacting on a human's behalf, with scoped/signed/revocable mandates (`research-loginid-company.md` §1, §3). This is company-stated strategy, not independently confirmed, and it's a genuine pivot from "passkey login for any site" to "identity/payment-authorization layer for AI agents."
46 - **AAuth / the broader machine-identity protocol space** (`research-oauth-history.md`) — a proposal (Dick Hardt, editor of OAuth 2.0/RFC 6749, not a co-author of OAuth 1.0/RFC 5849 — see that document for why the precise credential matters) addressing a different layer of the stack: authorizing *HTTP clients/agents* to services, versus FIDO2's job of authenticating *humans* to services. The two are complementary, not competing — a mature identity platform plausibly needs both, with passkey-based human authentication anchoring the root of trust that agent-identity protocols delegate from.
47
48 **Relevance to this assignment:** the same critique AAuth levels at static, pre-registered client credentials (API keys that leak, no portable identity, one-time upfront consent rather than per-request authorization) applies loosely to why a third-party-IDP connector built as a static username/password integration is a deliberately dated pattern — and why the connector's provider abstraction should be designed with an eye toward OAuth2/OIDC-style delegated flows as the natural next step, the same way `user_credential.method` is the seam for FIDO2 on the human-authentication side.
49
50 This belongs in the write-up as forward-looking context, not as scope creep — nobody asked for an agent-identity protocol implementation, and building one would bury the actual assignment under speculative complexity. One paragraph in the submission's narrative, pointing at the seam, is the right amount of ambition.
51
52 ## 4. Competitive takeaway for the submission's narrative
53
54 Framed as a one-line thesis for whoever reads the final packet: *this submission is a correctly-scoped, industry-standard credential baseline, built with explicit extension points toward FIDO2/passkey credentials, OIDC-style IDP federation, and (further out) agent-identity delegation — not because we claim to know that's what LoginID intended by the gap, but because those are the same extension points any CIAM backend eventually needs, and naming them shows the same market fluency without resting on an unfalsifiable read of the assignment author's mind.*
55 {
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/ai-workflow-narrative.md"
}1 # AI Tooling & Workflow Narrative
2
3 Owner: Marcus Ilori (`02-ai-security-architecture`). This is the full answer to LoginID's explicit ask ("describe any tool, framework, or AI used") — other tracks note their own AI usage briefly and point here for depth, per `../CLAUDE.md`.
4
5 This document states what was done, distinguishes what's independently documented by Anthropic from what this project composed on its own, and is deliberately precise about that boundary rather than overclaiming an official pattern that doesn't exist. Full source-by-source detail lives in `research-claude-architecture-best-practices.md`; this document is the applied summary a reviewer can read without the research doc.
6
7 ## Tool
8
9 **Claude Code**, Anthropic's agentic CLI. Chosen because the deliverable itself is an exercise in demonstrating AI-architect judgment — using the tool that is itself the subject of the "how did you use AI" question, rather than a general-purpose chat interface, lets the workflow be inspected as part of the submission.
10
11 ## Structure: a root brief plus five track-scoped briefs
12
13 The repository is organized as one root `CLAUDE.md` (the assignment text, track boundaries, cross-track working principles) plus five track directories, each with its own `CLAUDE.md` scoped to a distinct slice of the problem (product/industry framing, this track's security architecture, engineering delivery, infra/devops, data ops). Each track file is written to be self-contained enough that a fresh agent with no other conversation history can pick it up correctly given only that file plus the root file.
14
15 **What this composes, precisely:**
16
17 1. **The `CLAUDE.md` mechanism itself** is Anthropic's own, documented at https://code.claude.com/docs/en/memory — auto-loaded at session start, meant to be treated as living documentation, kept short and specific.
18 2. **The decision to fan that mechanism out per-directory, with a stable root file for cross-cutting state**, is this project's own application of a general principle Anthropic documents separately in "Effective context engineering for AI agents" (https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents): structured notes persisted to files outside the live context window, retrieved just-in-time rather than all loaded upfront, so a track's subagent carries only the ~1-2 files it actually needs rather than the whole project's accumulated context.
19
20 I'm stating this distinction explicitly because it matters to how this narrative should be read: Anthropic has not published a named "root CLAUDE.md + per-directory CLAUDE.md" pattern, and claiming they had would be exactly the kind of unsupported assertion this track's whole discipline argues against. What's true and defensible is narrower and still worth the credit — this is a reasonable, motivated composition of two things Anthropic documented separately.
21
22 ## Orchestration: scoped subagents per track, not one long single-context session
23
24 Each track is intended to be worked by an agent (in practice here, a persona-bearing teammate agent per track — see each track's `profiles/` entry) that reads the root file plus its own track file and works within that scope, rather than one agent holding all five tracks' context simultaneously and switching between them. This follows the orchestrator-worker pattern Anthropic describes in "How we built our multi-agent research system" (https://www.anthropic.com/engineering/multi-agent-research-system): a coordinating layer decomposes work into distinct-objective, distinct-tool-scope units; each unit works its own trajectory; only condensed results cross back, not raw exploration. Anthropic's own internal figure for a subagent-to-coordinator handoff is roughly 1,000-2,000 tokens of distilled findings — the discipline this project applies at track boundaries is the same: a track's `CLAUDE.md` plus its finished deliverable files (this document included) are what another track reads, never a transcript of how those conclusions were reached.
25
26 Concretely for this track: the threat model, the API auth/authz design, and the connector security design (`threat-model.md`, `api-auth-design.md`, `connector-security.md`) are the condensed artifacts `03-engineering-delivery` needs to implement against. It should read those files, not need this track's reasoning replayed turn-by-turn.
27
28 ## The org layer: personas, patterns, and debates (added after Phase 2 execution)
29
30 The structure above (root brief, five track briefs, scoped subagents) is the skeleton. What actually ran inside it, once Warren authorized hiring, was a second layer this project built on top of Anthropic's documented patterns rather than found in them: each of the five track leads hired a small sub-team of persona-bearing agents — this track hired four (Tomasz Wrede, an Adversary; Helena Marsh, a Principled Architect; Felix Adebayo, a Tinkerer and this lead's deliberate temperament opposite; Ingrid Solano, a Designated Skeptic) — and ran a small catalog of named orchestration patterns (red team/blue team, adversarial pair, structured written debate, independent second review, rotating devil's advocate, three hats) against specific design decisions, each ending in a durable decision file under `decisions/`. **This entire layer — the personas, the variance rules in `../CASTING.md`, the pattern catalog, the rule that every decision artifact records its own model/turn-count footer — is this project's own design, not an Anthropic-documented technique.** It's a deliberate, load-bearing extension built on top of the orchestrator-worker pattern (§"Orchestration" above), motivated by a specific risk Anthropic's own writing doesn't have to worry about but a five-lead org does: five track leads (and the PM) starting from the same base model land in the same temperament quadrant by default, and agreement between agents who all think alike is not evidence of a good decision.
31
32 ### What the pattern catalog actually bought, honestly measured
33
34 This is not a claim that multi-agent debate produces better designs than a single strong session **in general** — that claim was itself put to a structured written debate in this track (S5, `decisions/ai-workflow-claim-debate.md`) and the ruling is reproduced faithfully below, including the position that lost ground. What can be stated and evidenced:
35
36 - **S3 (red/blue, connector token lifecycle):** Tomasz's red seat produced five ranked attack leaves, each shaped as "add a control" (encrypt the cache harder, bind the token, rate-limit more). Felix's alternatives seat — a role that exists specifically to ask "does the thing being protected need to exist" rather than "is the control sufficient" — proposed deleting two of those leaves outright by removing the token cache entirely (fetch-per-call instead of cache-with-a-TTL), rather than hardening it. That's a concrete design change this document can point to that would not have existed without a seat whose brief was structurally different from the seat before it. Tomasz's own rebuttal turn then conceded two of the remaining three leaves cleanly (no new finding) and tightened the third — a fair count includes that clean concession as a null result, not just the hit. **Tally: 2 hits (deleted leaves), 1 tightening, 2 null (clean concessions).**
37 - **S8 (independent second review, three security-graded documents) — the most valuable process evidence in this track, worth stating as a general lesson rather than just an incident report.** `PLAN.md`'s original static roster assigned Helena to review both `threat-model.md` and `api-auth-design.md`. By the time S8 actually ran, that assignment was stale: Helena had since become S4's Proposer and authored the very scope-vocabulary and policy content in `api-auth-design.md` that S8 was meant to check, and had also been S3's blue seat authoring the controls in `connector-security.md`. Reviewing either would have been an author re-reviewing their own work under the label "independent review." **The lesson: independence is a property that has to be checked at the moment a review actually runs, not a role assigned once at planning time and trusted to still hold** — a five-track org where the same small hire roster accumulates authorship across successive debates will decay its own independence guarantees exactly this way if nobody re-checks before each review. Caught here before running (Helena reassigned to `threat-model.md` only, a document she never touched; Tomasz — clean, no Phase 2 involvement in this document — took `api-auth-design.md`; Ingrid kept `connector-security.md`, unaffected), not after.
38
39 With independence actually verified, the reviews earned their cost. **The concrete example worth leading with:** Tomasz found that the decomposition-resistant rate-limit counter S4 had just finished designing — specifically as Ingrid's own fix to her objection 4 in that same debate — was a silent no-op for `profile:read:any`, the scope with the broadest blast radius, because its exemption clause ("without a fresh `reason_code`") was unconditionally satisfied by a scope that already requires a `reason_code` on every call by design. Neither Helena (who wrote the fix) nor Ingrid (whose objection it answered) caught this, because both were reasoning inside "does this satisfy the objection as stated" — the same self-referential blind spot independent review exists to catch, now demonstrated on the review pattern's own prior output, one debate later. Ingrid's separate finding on `connector-security.md` — that a document entirely about protecting a vendor token's custody said nothing about who may call the connector holding it, or what the token authorizes once used — is a different class of question from the one the S3 red/blue run spent its effort on, for the same underlying reason: every S3 participant was already inside the "protect the token" frame. **Tally: 10 accepted findings across 3 documents, 0 declined, 0 null reviews** — a different, better hit rate than S3's, and both numbers belong in this document, not just the ones favorable to the claim.
40 - **Theo's Newcomer cold-read of `handoff-04-secrets.md` (04's track, not this one, but this track's document):** two real gaps (an undefined component's feature status, a trust-boundary reference that required reading a second track's file to parse) that survived this document's own author's review, caught by a reader with zero authorial investment. **1 hit.**
41
42 ### What the S5 debate actually settled — stated plainly, including the losing position
43
44 Felix argued (Position A) that this evidence shows the pattern produces better designs than a single strong session would. Ingrid argued (Position B) that two data points, both produced by roles this project built specifically to produce exactly this kind of catch, are not evidence of a general effect — a single session working from an explicit checklist ("can this control's leaf be deleted instead," "get one cold read before shipping") could plausibly produce the same catches without four personas and a debate protocol, and nobody has run or estimated that comparison. The ruling (`decisions/ai-workflow-claim-debate.md`) went **against** the general claim and **for** a narrower one: the defensible statement is a structural/mechanism argument, not a statistical one — a seat holding a genuinely different optimization target, or a reader with no authorial stake, can surface a class of error that self-review structurally tends not to, because self-review interrogates the answer it already committed to rather than the frame that produced the question. That is not the same as asserting multi-agent design beats solo design as a population-level effect, and this document does not make that broader claim. Ingrid's insistence that null results (the S3 leaf conceded clean, S8's near-zero null rate by contrast) be counted in the same tally as hits was adopted outright and is reflected in the tallies above, not just the successes.
45
46 ### The external operator agent: named, never contacted
47
48 A real external agent exists in this environment: an operator, human or automated, holding cluster-admin or namespace-admin rights on 04's design-only Kubernetes lab cluster, capable of applying manifests to it. This track's threat model names that operator as a privileged actor and trust boundary (`threat-model.md`, Assumption 6) — not a hypothetical, a real agent this project could technically reach. **It was deliberately kept out of scope for the entire duration of this project: never contacted, nothing deployed, no manifest applied.** This is stated here because an AI-workflow narrative that documents every agent this project *did* use would be incomplete, arguably misleading by omission, if it didn't also state plainly which real agent in the environment it declined to use and why — the boundary was a design decision (design-only reference target), not an oversight or a capability gap.
49
50 ## What was AI-generated vs. human-directed
51
52 - **Human-directed**: the assignment itself (LoginID's), the five-track decomposition and ownership boundaries (the root `CLAUDE.md`), and every judgment call in this document and its siblings about *which* security mechanism to choose and why (e.g., OAuth2 client-credentials over static API keys in `api-auth-design.md` — a reasoned choice, not a default).
53 - **AI-generated, human-directed content**: the research pass that produced `research-claude-architecture-best-practices.md`, and the drafting of this narrative, the threat model, and the design documents themselves, all produced within this track's scope by an agent operating under this track's `CLAUDE.md` and persona brief.
54 - **Explicitly not claimed**: that any of this constitutes a validated, "certified" methodology. The research doc's §6 covers Anthropic's Claude Certification Program by name and is careful that its existence is not evidence this project's specific techniques are exam-validated — that would be a citation used to imply authority it doesn't carry.
55
56 ## Why this discipline matters for this specific track
57
58 This track's whole mandate is "name assumptions, cite sources, treat security design as a first-class deliverable." Applying the identical standard to the AI-workflow claims — say precisely what's sourced, say precisely what's this project's own extension, don't blur the two — is the same rigor applied to a different subject. A reviewer evaluating "how you use AI tooling" should be able to trust this document's citations exactly as much as they can trust the threat model's.
59
60 ## Sources
61
62 Full list with URLs in `research-claude-architecture-best-practices.md`. Load-bearing ones for this document specifically:
63
64 - https://code.claude.com/docs/en/memory
65 - https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
66 - https://www.anthropic.com/engineering/multi-agent-research-system
67 - https://claude.com/blog/subagents-in-claude-code
68 {
"file_path": "/Users/warrenthompson/Source/LoginID/LOG.md"
}1 # Project Log
2
3 Append-only journal of what happened, who did it, and why — owned by the PM agent (Dana Whitfield / team-lead). Track leads record their own progress in their `PLANNING.md` row and their track's `CLAUDE.md` Status; cross-track events land here. Times are America/New_York, approximate where reconstructed. Entries before 2026-09-12 10:45 were backfilled from the session transcript when this file was created.
4
5 ## 2026-09-12
6
7 **09:17 — Scaffolding.** Warren framed the take-home as a five-track "org" to demonstrate AI-architecture capability rather than answer three coding questions in isolation. Team-lead created the root `CLAUDE.md` (assignment text verbatim, track table, working principles) and five track directories, each with a self-contained `CLAUDE.md` stating what the track owns and does not own. No code.
8
9 **09:20–09:28 — First research wave (six scoped subagents, one per topic, run in parallel).** Findings that changed the framing: the interview reference to "a-auth.dev / Dick Hart" is actually **aauth.dev by Dick Hardt** — editor of OAuth 2.0 (RFC 6749) and creator of OAuth WRAP, *not* an OAuth 1.0 co-author (RFC 5849's byline was checked); AAuth is an agent-identity proposal complementary to, not competing with, LoginID's human-passkey space. LoginID research: founded ~2018–19 by Simon Law (CEO) and Jim Brown (CRO), FIDO2-certified, seed funding 2019–21, and an apparent 2025–26 repositioning toward AI-agentic-commerce identity (flagged as marketing-sourced). Claude-architecture research established the orchestrator-worker, condensed-hand-off, and context-engineering principles later used in `02-ai-security-architecture/planning-approach.md`. Go, infra, and data-ops research produced the stack and strategy recommendations each track later adopted (sqlx+pgx / modernc sqlite / net/http / bcrypt→Argon2id / golang-jwt; distroless + GitHub Actions + compose profiles; UUID PKs, `auth_method` lookup table, shared migrations with per-backend overrides).
10
11 **09:30 — PM persona.** "Dana Whitfield" generated with profile-gen on the local ComfyUI server (juggernautXL, no LoRA); the validated workflow was saved to `profiles/dana-whitfield/comfyui-workflow.json` and reused for every later portrait.
12
13 **09:34–09:50 — Track-lead personas and standing team.** Five lead personas generated (Naomi Voss 01, Marcus Ilori 02, Renata Cole 03, Theo Bergman 04, Priya Nandakumar 05), each wired into its own track `CLAUDE.md`. Portraits were serialized because the ComfyUI box is shared with other agents on this machine (one job hit a 600 s client timeout under load and was retried). A standing team of five lead agents was spawned and onboarded against root + track `CLAUDE.md` files.
14
15 **09:47–09:53 — First-wave design deliverables.** 01: `industry-framing.md`, `personas-use-cases.md`, `api-connector-design-rationale.md`. 05: schema DDL, Go repository interfaces, `multi-db-strategy.md`, `pii-governance.md`. 02: `threat-model.md`, `api-auth-design.md`, `connector-security.md`, `ai-workflow-narrative.md`. 02 and 05 coordinated directly (not via team-lead) on modeling `user_credential.method` as a lookup table — later recorded as the worked example of a bounded two-party decision.
16
17 **~09:50 — Incident and rule.** The 01 lead toggled the root PM persona's display off from inside its track. Reverted by team-lead; rule recorded: tracks never modify root-level project state — flag it instead.
18
19 **~09:58 — Return to planning; code wiped.** Warren, speaking directly in the 03 lead's session, said the project is still in planning — no code. Renata backed out the DAO scaffolding she had started on team-lead's earlier go-ahead. Warren then directed a restart: all code-shaped artifacts deleted (`05-data-ops/repository_interfaces.go`, `05-data-ops/schema/*.sql`); Priya folded the interface contract and schema shape into prose in `multi-db-strategy.md`. Each lead moved its working directory into its own track.
20
21 **~10:15 — Session resumed; team respawned.** Leads respawned directly into their own directories (verified against each pane's status bar, not self-report). Root cause of "every pane shows Dana" found: the global `SessionStart` hook runs `show_profile.py --root "${CLAUDE_PROJECT_DIR:-$PWD}"` before a subagent's first turn, and `CLAUDE_PROJECT_DIR` is the project root for every subagent. Product feedback drafted (Agent tool lacks a `cwd` parameter). Each lead now displays its own persona explicitly.
22
23 **10:31–10:36 — Planning approach.** At Warren's request, Marcus (02, the AI-architecture owner) gathered input from all four other leads and wrote `02-ai-security-architecture/planning-approach.md`: model/effort by role (orchestrator fable/high; leads sonnet; 05 planning at opus/high; research fan-out haiku/low; implementation sonnet, opus only for the connector token lifecycle), orchestrator-worker with bounded two-party exceptions, the "receiver can act without re-deriving the sender's reasoning" hand-off standard, dependency order 01/02/05 → 03 (split start) → 04, and a thin root `PLANNING.md` board.
24
25 **~10:45 — Org plan approved.** Warren's directive: every lead hires a sub-team with deliberate personality/archetype variance across the org; every agent gets a profile-gen persona (personality, history, technical background; portraits via background jobs); orchestration patterns use those personas to drive debate; everyone plans in plan mode first; Jira stories in project `LT` after planning. Team-lead drafted the org plan in plan mode with two research passes (profile-gen multi-persona mechanics; Claude Code agent-definition, nested-spawn, and plan-approval mechanics) and one design pass (archetype palette, variance rules, debate protocols). Warren chose the larger org (3–4 hires per track, cap 18) and portraits for all hires. Plan saved in-repo as `PLAN.md`.
26
27 **Phase 0 — Scaffolding for the planning phase (this entry).** Created `PLAN.md`, this `LOG.md`, `PLANNING.md` (coordination board), `CASTING.md` (archetype palette, variance rules, persona standard, pattern catalog, registry), `.claude/AGENT_TEMPLATE.md` (moved out of `.claude/agents/` after it auto-registered as a bogus `SLUG` agent type) (hire definition template with a once-only per-agent `PreToolUse` display hook — agent frontmatter turned out not to support `SessionStart`, so the first tool call is the trigger), `.claude/settings.json` (haiku effort → low), `scripts/portrait-queue.sh` (serialized background portrait renderer), `.gitignore`; updated root `CLAUDE.md`; initialized git with the first milestone commit. Warren added a rule mid-phase: deep research (patents, whitepapers, standards, primary sources) is authorized and run only by track leads; hires request it. Recorded in `CASTING.md` §3 and root `CLAUDE.md`. Milestone commit `3fef95c` (43 files).
28
29 **Phase 1 launched.** Team-lead sent each lead its planning brief (plan mode; four mandatory sections — deliverables/tasks, hire roster, debate plan, dependencies; hire targets 01:3, 02:4, 03:4, 04:3, 05:4; no profile-gen or spawning before an explicit "hiring go"). Per `planning-approach.md`, the 05 lead was relaunched on Opus for the planning phase (model is fixed at spawn); the Sonnet session stood down and its pane was parked in a tmux window named `idle`. The relaunched lead is addressed as `priya-nandakumar-2` in team messaging; the persona and slug remain `priya-nandakumar`.
30
31 **Phase 1 — first plan approved (04).** Theo's plan: five deliverables each gated on a named hand-off, three hires (Newcomer / Enthusiast / Minimalist — the Enthusiast is his required temperament opposite and the org's first playful+bold seat), one newcomer's-question pass, everything else declared waste with reasons. "Hiring go" issued. Housekeeping: the agent template had auto-registered as a bogus `SLUG` agent type by living in `.claude/agents/`, so it moved to `.claude/AGENT_TEMPLATE.md`.
32
33 **Deployment-target decision (Warren, via the 04 lead's session).** A real Kubernetes dev/lab cluster exists on Warren's LAN, operated by an external OpenClaw agent ("amber-kubernetes") on a Mac mini reachable over SSH. Warren's call, confirmed in that session: for this submission it is a **design-only reference** — 04's containerization/CI/secrets deliverables name it as the concrete deployment target and describe amber-kubernetes as the operator who would apply manifests, but nothing is deployed and the agent is not contacted. A real demo deployment would be a separate, explicit decision by Warren. Relayed to 02 (secrets-delivery assumptions; a real external agent in the environment is worth a sentence in `ai-workflow-narrative.md`).
34
35 **Phase 1/2 — plans 03 and 01 approved; hiring procedure corrected.** Renata (03): six deliverables in dependency order, service boundaries filled in (`PLANNING.md`), four hires — "hiring go" conditional on renaming her Veteran, whose first name collided with Theo's Minimalist. Naomi (01): six Stories + one Task framed as "stress-test and harden," three hires, a bounded loan of 04's Newcomer for two cold reads — approved. Then Theo hit a platform constraint while spawning: the team roster is flat (a teammate cannot spawn a named teammate) and custom agent types written mid-session are not visible in a teammate's session. Resolution: leads do steps 1–4 (persona, definition, registry) and send team-lead a spawn request; team-lead spawns hires as named teammates that report to their lead. Verified immediately by spawning Theo's three hires from the team-lead session with their custom agent types (`wesley-okonkwo`, `bree-sandoval`, `callum-ferreira`) — all resolved. `CASTING.md` §6 step 5 and `PLAN.md` Phase 2 step 5 rewritten. Portrait queue started (first render: Wesley).
36
37 **QA question (Warren → Renata, relayed).** Renata recommends keeping test strategy and QA inside 03: a sixth track would cost a peer lead, 3–4 hires against a full cap, and a dependency edge on a three-question assignment; the review independence `planning-approach.md` requires on security-graded surfaces is met by routing the connector token-lifecycle code and auth middleware to 02 for the second pass (Marcus owns the design being implemented and is independent of her authorship), while Oren (Detail Hawk) does ordinary first-pass code review. Second choice if Warren wants structural independence: one "Independent Reviewer" hire reporting to Marcus, not a track. Decision pending Warren.
38
39 **Org topology change (Warren).** On seeing the flat-roster constraint, Warren directed that each track lead operate as its **own top-level Claude Code session in its own tmux window**, launched one at a time from its track directory — not as a subagent of the PM session. Effects: a lead can spawn and own its hires as named teammates in its own window; the lead's session loads its track `CLAUDE.md` and persona natively (which also ends the "every pane shows the PM's picture" problem at the source); the PM coordinates leads by cross-session messaging. Team-lead stood down the five lead subagents and the three 04 hires it had spawned on Theo's behalf (all state is on disk), rewrote `CASTING.md` §6 step 5 and `PLAN.md` Phase 2 step 5 back to "leads spawn their own hires," added the topology to root `CLAUDE.md`, and began relaunching leads in order 04 → 02 → 03 → 01 → 05 with `claude --model <model> --permission-mode auto "<onboarding prompt>"` in a new tmux window per track.
40
41 **Phase 2 — four teams live under the new topology.** Relaunch worked first time for every lead: `04-infra-devops-47` (Theo; spawned Wesley/Bree/Callum himself), `02-ai-security-architecture-a2` (Marcus; Tomasz Wrede, Helena Marsh, Felix Adebayo, Ingrid Solano), `03-engineering-delivery-b7` (Renata; Oren Castellan, **Nolan Reyes** — renamed from Callum Reyes to clear the first-name collision — Marisol Ferran, Ines Dabrowski), `01-product-industry-research-design-21` (Naomi; Imogen Hale, Desmond Okafor, Tobias Lindqvist). Two harness details worth recording: the first launch stopped at Claude Code's workspace-trust prompt and at an "allow external CLAUDE.md imports?" prompt (the parent `CLAUDE.md` imports the PM persona from outside the track directory) — team-lead accepted trust and **declined** external imports so each lead loads only its own track persona; later launches inherited the trust decision. Before their stand-down arrived, the old 01/02/03 subagent leads had already completed hiring steps 1–4 (personas, definitions, registry rows); the new sessions verified those on disk rather than recreating them, so there are exactly 14 registry rows, no duplicates. Session names recorded in `PLANNING.md` "Sessions" so leads can message each other directly. Portrait queue restarted for the eleven new rows. 05 (Priya, Opus) launched last with a Phase 1 brief — her `PLAN.md` did not yet exist. Hires: 14 of 18.
42
43 **Phase 1 complete — fifth plan approved (05).** Priya's plan (session `data-ops-planning-phase`, Opus): six stories with the Go-shaped contract in `multi-db-strategy.md` as the critical-path S1 (interface signatures, query/result types, error sentinels, field-level schema with nullability, numbered out-of-scope list for 03), S2 adversarial pair on `Search()`, S3 three hats on the multi-DB abstraction with codegen argued properly, S4 `migration-approach.md` for 04, S5 numbers and a deletion-job mechanism for `pii-governance.md`, S6 cold-read gate on S1; S4/S5 run parallel to S1 so 04 is not idle. She caught a cross-track mismatch — 03's factory returns one `dao.Repository`, her contract defines three interfaces — and proposed a composite exposing `Profiles()/Credentials()/Methods()` behind Renata's declared signature; routed as a bounded two-party decision with 03. Roster: Anders Vogel (Systems Cartographer), Saoirse Byrne (Storyteller, her opposite), Yusuf Karadag (Detail Hawk), Beatriz Achterberg (Spreadsheet) — every archetype now at exactly 2× org-wide with distinct functions; Skeptic and Newcomer were at cap, so Yusuf and Saoirse take those seats. Hiring go issued; org at the 18-hire cap. Variance outcome across the org: 12 of 13 archetypes used (Consensus Weaver unused — the leads and PM already cover that temperament), 6 hires tagged playful/bold against a minimum of 3, every team holds its lead's opposite.
44
45 **Phase 2 complete — 18 of 18 hires live.** 05 spawned Anders Vogel, Saoirse Byrne, Yusuf Karadag, Beatriz Achterberg in its own session; the factory mismatch closed with 03 in two turns (Renata chose the accessor-method composite over a struct-with-fields because an interface stays mockable for her test strategy — recorded in her Service boundaries section and 05's row). Priya is deliberately keeping Saoirse unexposed to `multi-db-strategy.md`/`pii-governance.md` until the S1 draft exists, so the cold read is genuinely cold. Ops fixes: the portrait renderer's status writer was off by one column (it appended "done" after the closing pipe while the Portrait column stayed `queued`); fixed, all 18 registry rows normalized (14 `done` — leads had rendered their own hires' portraits during hiring — 4 `queued` for 05, renderer relaunched). Hires whose display hook fired before their PNG existed show name-only until they re-run `show_profile.py`; 02 and 03 asked to have theirs do so. All 18 portraits rendered by 11:30.
46
47 **Phase 3 — first debate artifacts and both 02 hand-offs land.** 02 delivered `handoff-04-secrets.md` (v1; Theo's Newcomer cold read caught two real gaps, both fixed before v1), `decisions/connector-token-lifecycle-redblue.md`, `decisions/search-authz-scoping.md`, and `handoff-03-auth.md`, each with the §5 footer (model, turn count). **First debate-driven design change:** the red/blue on the connector token lifecycle ended in *no-cache-by-default* for the vendor token — Felix Adebayo (Tinkerer, Marcus's temperament opposite) used the alternatives seat to delete two attack-tree leaves outright rather than add controls; the only accepted residual is bearer-token replay (no vendor proof-of-possession lever), no follow-up Story needed. Both hand-offs went directly to 03 and 04, which unblocks Renata's middleware and Theo's secrets-delivery design. Remaining in 02: S5 (structured debate on the AI-workflow narrative claim), S8 (independent second review), S9/S10.
48
49 **Phase 3 — track 01 complete.** Naomi closed S1–S6: the claims audit (Desmond, Spreadsheet) softened or strengthened three claims; the structured written debate on the password-baseline framing (Imogen for, Tobias against, Naomi ruling) **dropped the "LoginID designed this gap on purpose" claim as unfalsifiable** and revised `industry-framing.md` §2/§4; `api-connector-design-rationale.md` gained a "Contract summary for 03" and an out-of-scope list, then Wesley's cold read (the 04 loan, 2 of ≤4 turns) fixed one real ambiguity; `personas-use-cases.md` gained a search-authorization requirements appendix from Tobias's objection (volume-limiting, masking independent of query specificity — an input to 02's authz ruling); `ux-notes.md` written and stripped of two claim-like sentences. Artifacts: `decisions/claims-audit.md`, `decisions/password-baseline-debate.md`, `decisions/rationale-cold-read.md`. Also on the board: 03 accepted `handoff-03-auth.md` (cold read clean) and folded `DB_DSN_FILE` into its config surface per 02's secrets hand-off; 05 drafted `migration-approach.md` for 04 (single-runner requirement is platform-enforced — `pg_advisory_lock` is unavailable on CockroachDB).
50
51 **Phase 3 — track 03 planning debates closed.** Renata's team closed four decisions while gated on 05's contract, all in `03-engineering-delivery/decisions/`: go-layout (two binaries confirmed; `internal/app` shared bootstrap adopted; outbound timeout/backoff added to the connector spec), error-semantics (closed sentinel set; mandatory translation at the HTTP boundary and per backend driver; explicit context-error handling), context-propagation (transactional scoping, deadline budgeting, named detach points for credential writes), test-double-strategy (hand-written fakes retained; "update the fake in the same PR" as a review checklist item). Cross-track: a host-colocation ambiguity flagged to 04 was resolved by 04 adding resource limits; 02's independent second review (Ingrid, S8) caught a connector self-authentication gap that 03 acknowledged for its connector story. 03 is now ready to implement the moment two things exist: 05's S1 contract and Warren's explicit go.
52
53 **Phase 3 — track 04 deliverables drafted.** Theo's five design deliverables landed against real inputs (03's service boundaries, 02's secrets hand-off, 05's provisional migration approach): containerization (Dockerfile shape and a Kubernetes manifest sketch targeting the lab cluster as a design-only reference, including a NetworkPolicy adopted after Bree Sandoval's push), CI pipeline (a CockroachDB test job added on Bree's case), local dev loop (compose with two DB profiles; hot-reload adopted), secrets delivery (implements 02's inventory row by row), observability (never-log enforcement mechanism; tracing cut to a single line on Callum Ferreira's scope-guard pass). Wesley's newcomer cold read of the boundaries and secrets inputs is recorded in `04-infra-devops/decisions/`. Nothing deployed, no contact with the cluster operator. 01, 03, and 04 have now completed their Phase 3 planning work; 02 is on S8–S10; 05's S1 contract remains the critical path.
54
55 **Phase 3 — 02 closes S5 and S8.** S5 (structured written debate on the AI-workflow narrative's core claim; `decisions/ai-workflow-claim-debate.md`) was ruled per point: Felix's mechanism-level claim — a differently-tasked seat catches what self-review cannot — held as a structural argument, not a statistical one; Ingrid's objections that n=2 supports no general law and that null result
… truncated (20 KB cap){
"file_path": "/Users/warrenthompson/Source/LoginID/CASTING.md"
}1 # Casting Registry — hiring rules, archetypes, and orchestration patterns 2 3 Every agent in this project is a "hire" with a profile-gen persona: a name, a temperament, a plausible professional history, a technical background, and a stated blind spot. This file is how the org keeps those hires **varied across teams, not just within one** — five leads who all hire clones of themselves would produce agreement, and agreement is not what a design review is for. Team-lead (Dana Whitfield) owns everything above the registry table; each lead owns its own rows. 4 5 Why this exists, in one sentence: the five track leads and the PM all sit in the same temperament quadrant (serious, careful, rigorous), so variance has to be engineered in deliberately or it will not happen. 6 7 ## 1. Archetype palette 8 9 Thirteen archetypes. Each has a characteristic blind spot on purpose — the lead pairs against it. Playful and quirky is a legitimate professional register here; caricature is not. 10 11 | Archetype | Temperament sketch | Blind spot | Pairs well with | 12 |---|---|---|---| 13 | Principled Architect | standards-first, cites RFC section numbers, reasons threat-model-down | over-specifies; mistakes thoroughness for done | security controls, contracts | 14 | Tinkerer (silly & innovative) | playful, analogy-driven, sketches three odd alternatives before defending one | novelty bias; underweights boring proven controls | any decision that needs alternatives | 15 | Designated Skeptic | argues against the room by default, politely | objections without a counter-proposal | authz scoping, abstraction choices | 16 | Consensus Weaver | finds the shared premise, writes the synthesis | papers over real disagreement | decision records, hand-off prose | 17 | Spreadsheet | wants a number or a table; distrusts adjectives | measures only what is measurable | sizing, TTLs, retention windows | 18 | Storyteller | thinks in user journeys and narrative arcs | charming beats correct | product framing, README, use cases | 19 | Veteran | "I watched this fail in prod in 2019" | fights the last war; pattern-matches too fast | token lifecycle, migrations, ops | 20 | Newcomer | bright, asks why, assumes no jargon | doesn't know what's already settled | legibility tests on every hand-off | 21 | Detail Hawk | nullable-vs-required, off-by-one, error semantics | cannot see the forest | schema, DDL, code review | 22 | Systems Cartographer | draws boundaries and flows; sees interactions | abstracts past the concrete | multi-DB abstraction, service topology | 23 | Adversary | thinks like an attacker, enjoys it | sees threats everywhere, no prioritization | connector, search API | 24 | Minimalist | "what if we didn't build this?" | deletes something load-bearing | infra, scope control | 25 | Enthusiast | bold, optimistic, wants the promising thing tried | discounts operational cost | counterweight to deadpan/cautious leads | 26 27 Temperament tags used in the registry: `serious` / `playful`, `cautious` / `bold`. 28 29 ## 2. Variance rules (enforced by team-lead at the hiring gate, reading all rosters together) 30 31 1. No archetype more than **twice org-wide**, and never twice within one team. 32 2. No two leads hire the same archetype for the same **function** — two "Detail Hawk reviewers" is duplication, not variance. An archetype used twice must do different work (e.g., Detail Hawk as code reviewer in 03 and as DDL/nullability reviewer in 05). 33 3. Every team includes its lead's **temperament opposite**: Naomi (curious narrative analyst) → Spreadsheet; Marcus (rigorous citer) → Tinkerer; Renata (clarity-over-cleverness shipper) → Enthusiast; Theo (deadpan minimalist) → Enthusiast or Newcomer; Priya (schema-first PII guardian) → Storyteller. 34 4. Org-wide, at least **three** hires are tagged `playful` + `bold`, because zero leads are. 35 5. Each roster spans at least two temperament axes (serious↔playful and cautious↔bold), not one. 36 6. Rosters are approved in arrival order; later leads take what the palette has left. Team-lead breaks ties. 37 38 ## 3. Sizing 39 40 3–4 hires per track, **18 total, hard cap 18** (Warren's call). Thirteen archetypes at ≤2× each is 26 slots, so the cap fits with no repeats inside any team. Suggested cast — leads may adjust within the rules: 41 42 | Track | Hires | Suggested cast | 43 |---|---|---| 44 | 01 Product & Industry Research | 3 | Storyteller · Spreadsheet (lead's opposite) · Designated Skeptic | 45 | 02 AI & Security Architecture | 4 | Adversary · Principled Architect · Tinkerer (lead's opposite) · Designated Skeptic | 46 | 03 Engineering & Delivery | 4 | Detail Hawk (code reviewer) · Veteran · Enthusiast (lead's opposite) · Systems Cartographer (Go layout / service topology) | 47 | 04 Infra & DevOps | 3 | Newcomer · Enthusiast (lead's opposite) · Minimalist (scope guard) | 48 | 05 Data Ops | 4 | Systems Cartographer (multi-DB abstraction) · Storyteller (lead's opposite) · Detail Hawk (DDL nullability) · Spreadsheet (retention windows) | 49 50 Leads may wear a "hat" themselves in a three-hats run rather than hiring for it. Hires do **not** spawn subagents (nested-spawn depth cap); any research fan-out is done by the lead with haiku/low throwaway subagents. 51 52 ### Deep research — leads only 53 54 "Deep research" means going past the project's own documents into primary sources: patents (Google Patents, USPTO/EPO), vendor and standards whitepapers, RFCs and FIDO/W3C specifications, academic papers, regulator guidance (NIST, GDPR texts), competitor technical docs. It is expensive, it is where hallucinated citations come from, and it shapes decisions other tracks build on — so it is **authorized and run only by track leads** (and team-lead). Rules: 55 56 1. A hire may *request* deep research from its lead with a one-line question and why the existing docs can't answer it; a hire never spawns a research subagent or performs deep research itself. 57 2. The lead decides whether the question earns it (does the answer change a decision another track depends on?), then fans out haiku/low or sonnet/low research subagents with a tightly scoped question each, in parallel. 58 3. Output lands as `<track-dir>/research-<topic>.md` with a source URL for every claim, marketing sources labeled as such, and an explicit "could not verify" list — the same standard the six first-wave research docs already follow. 59 4. Patents and whitepapers are cited for *what they claim*, never as proof the claim is true; the lead notes where a claim is contested. 60 5. The lead records the research in its `PLANNING.md` row and cites the file in the decision it informed. 61 62 ## 4. Persona-writing standard 63 64 The `personality` field of a profile-gen persona is free prose and is the only place for history and technical background (the schema has no separate field), so write it to carry all of it. **90–150 words**, in this order: 65 66 1. Temperament — two adjectives and how they show up in a meeting. 67 2. Decision style. 68 3. **What they specifically push back on.** 69 4. Plausible history — two prior roles, generic employers, no real names. 70 5. Technical background tied to the function they are hired for. 71 6. **Their own blind spot, stated plainly**, so the lead can pair against it. 72 7. How they behave in disagreement. 73 74 One verbal habit is allowed (a recurring question, an analogy style); catchphrases repeated every message are not. Avoid: real people or companies, checkable credentials, stereotypes, sarcasm at a person's expense, profanity, emoji, and anything about appearance (that belongs in the portrait prompt, not the persona). No two hires in the org may share a first name with a lead or with each other. 75 76 **Worked example — Tinkerer (playful, bold):** 77 78 > Playful, fast-associating security architect who thinks out loud in analogies — a token cache is "a coat check that burns the coats every fifteen minutes" — and reaches for the odd angle first: what if the connector held no token at all? Six years building fraud-detection pipelines at a payments startup, three at a mid-size IAM vendor, mostly Go and Rust; his prototypes broke in ways nobody had thought to test for, which was the point. Decides by sketching three alternatives before defending one, and cheerfully drops his own when shown a cleaner failure mode. Pushes back on "that's the standard approach" whenever nobody can say what the standard protects against. Blind spot: novelty bias — he underweights boring, proven controls, so pair him with someone who cites the RFC. Warm in disagreement, never sarcastic. 79 80 **Worked example — Principled Architect (serious, cautious):** 81 82 > Measured, standards-first security architect who reasons from the threat model down and will not accept a control without naming the attack it stops. Twelve years across a bank's identity platform and a public-sector PKI program; deep in OAuth2/OIDC, JOSE, and the failure modes of JWT libraries, reads RFCs as primary sources and quotes section numbers. Decides slowly and writes decisions as records with alternatives considered. Pushes back on any trade of a security property for convenience, and on claims without a source. Blind spot: over-specification — she designs for threats the assignment doesn't have and can mistake thoroughness for finishedness, so pair her with a pragmatist asking what the take-home actually needs. Courteous, unhurried, unbothered by challenge; changes position when shown evidence, and says so. 83 84 ## 5. Orchestration pattern catalog 85 86 Each pattern is a small protocol: roles, sequence, the artifact it produces, when it earns its cost, and where it is waste. Cost is in agent-turns (one hire invocation and reply). Every run ends in a durable file in the track directory (`<track>/decisions/<topic>.md`: positions, rebuttals, the lead's synthesis and decision) — chat is not the record. The AI-architecture owner (02) ratifies and may amend this catalog. *Ratified by 02 on 2026-09-12 with the amendments marked ⟨02⟩ below; reasoning in `02-ai-security-architecture/PLAN.md` §0.* 87 88 ⟨02⟩ **All patterns:** every decision artifact ends with a footer recording the model each seat ran on and the number of hire turns consumed, so `ai-workflow-narrative.md` can cite measured cost rather than intent. 89 90 **Adversarial pair.** Proposer drafts; Designated Skeptic writes numbered objections; Proposer revises; lead rules. 3 turns + lead. ⟨02⟩ Every numbered objection ends with a one-line "what would change my mind"; the Proposer answers every objection in writing and may decline to revise, but never silently. Artifact: the decision with an "objections considered" section. *Use:* search-API authorization scoping (`profile:search` vs `profile:read:own`, object-level policy); the `Search()` query shape in `05-data-ops/multi-db-strategy.md`. 91 92 **Red team / blue team.** Adversary writes an attack tree against one surface; Principled Architect answers each leaf with a control or an explicitly accepted risk; lead scores residual risk. 2–3 turns. ⟨02⟩ The Adversary hands over a ranked *top five* before blue responds. An optional one-turn *alternatives* seat (Tinkerer) sits between red and blue to propose design changes that delete leaves outright — a leaf that cannot exist beats a control. Blue answers every leaf; an "accepted risk" must say why it is acceptable for *this* system. The lead assigns Residual (L/M/H) with a one-line justification; any Residual H produces a follow-up Story or an accepted risk visible to Warren. Table shape: `02-ai-security-architecture/planning-approach.md` Appendix A. Artifact: attack-tree table appended to `02-ai-security-architecture/connector-security.md`. *Use:* the connector token lifecycle only (TTL, at-rest encryption, per-vendor keying, fail-closed behavior). *Waste:* DAO CRUD, Dockerfiles. 93 94 **Three hats.** Tinkerer (optimist), Veteran (pessimist), Spreadsheet or Minimalist (pragmatist) each write ≤300 words on one choice, in parallel; lead synthesizes. 3 turns + lead. *Use:* the multi-DB abstraction (per-driver implementations vs. dialect branches vs. codegen); Go layout (one binary vs. two). A lead may take a hat. 95 96 **Rotating devil's advocate.** A standing "Objection" seat that moves to a different hire per decision so nobody becomes "the negative one". 1 turn per decision, appended as a short Objection block. *Use:* 03's implementation calls (error semantics, context propagation, test doubles). Cheapest pattern; run it often. 97 98 **Structured written debate.** Position → rebuttal → lead synthesis, with both positions preserved in the record. 3 turns. ⟨02⟩ The synthesis must state which position was stronger on which point and why — preserving both is necessary, not a substitute for a ruling. *Use:* 01's central framing (is a password baseline a deliberate simplification or a contradiction at a passkey company?); 02's core claim in `ai-workflow-narrative.md`. 99 100 **Newcomer's question.** Newcomer reads a finished hand-off cold and lists what it could not act on without re-deriving the sender's reasoning; the author fixes. 1–2 turns. *Use:* every 05→03 and 02→03 hand-off, the README, `PLANNING.md`. This directly tests the project's hand-off standard and is the highest-ROI pattern here. 101 102 **Where patterns are waste.** Never debate what the assignment text fixes (endpoint paths, JSON bodies). 04's track is small: one Newcomer's-question pass on 03's config surface and 02's secrets inventory, nothing more. 103 104 ## 6. Hire-creation procedure (after team-lead's explicit "hiring go") 105 106 1. Confirm the archetype is still available under the rules above. 107 2. Create the persona **text-first** with profile-gen: `write_profile.py --fields-file <fields.json> --root <track-dir> --output file --assets tracked` — `personality` written to §4; `image` pre-declared as `profiles/<slug>/<slug>.png`; `display.autostart: false`; `generation.backend: comfyui` with the intended portrait prompt and negative prompt. The persona lands at `<track-dir>/profiles/<slug>/<slug>.md`. **Never add a hire as a `profile-gen` marker in any `CLAUDE.md`** — the autostart hook displays every marker it finds. 108 3. Create `.claude/agents/<slug>.md` from `.claude/AGENT_TEMPLATE.md` (kept outside `.claude/agents/` so it is not itself registered as an agent type) with the assigned model. 109 4. Append the registry row below with `Portrait: queued`. 110 5. **Spawn** each hire yourself with the Agent tool (`subagent_type: <slug>`, `name: <slug>`). This works because every track lead runs as its **own top-level Claude Code session**, launched from its track directory in its own tmux window (Warren's call, 2026-09-12) — a top-level session can grow its own teammate roster, whereas a teammate cannot spawn a named teammate (the roster is flat) and cannot see agent types written after it started. Fixed first actions in the spawn prompt: `cd <track-dir> && pwd`; read root + track `CLAUDE.md`, `./PLAN.md`, own persona; run the `show_profile.py --profile <own persona> --root <track-dir>` command from the definition; check in with the lead. Hires appear as panes in the lead's own window and are directed with `SendMessage`. Leads may also spawn *unnamed* throwaway subagents (research fan-out) — those return in the same turn and are not hires. 111 112 Team-lead (the PM session) runs `scripts/portrait-queue.sh` in the background; it renders queued portraits one at a time on the shared ComfyUI box and flips each row to `done`. Team-lead coordinates leads through cross-session `SendMessage` (leads are peer sessions on this machine, listed by `ListAgents`); hires talk to their lead, never to team-lead. 113 114 ## 7. Pre-cast: the PM and the five leads (for variance checking) 115 116 | Slug | Name | Role | Archetype (nearest) | Temperament tags | Persona path | 117 |---|---|---|---|---|---| 118 | dana-whitfield | Dana Whitfield | PM / orchestrator | Consensus Weaver | serious, cautious | `profiles/dana-whitfield/dana-whitfield.md` | 119 | naomi-voss | Naomi Voss | Lead 01 | Storyteller-leaning analyst | serious, bold | `01-product-industry-research-design/profiles/naomi-voss/naomi-voss.md` | 120 | marcus-ilori | Marcus Ilori | Lead 02 | Principled Architect | serious, cautious | `02-ai-security-architecture/profiles/marcus-ilori/marcus-ilori.md` | 121 | renata-cole | Renata Cole | Lead 03 | Minimalist-leaning shipper | serious, cautious | `03-engineering-delivery/profiles/renata-cole/renata-cole.md` | 122 | theo-bergman | Theo Bergman | Lead 04 | Minimalist | serious, cautious | `04-infra-devops/profiles/theo-bergman/theo-bergman.md` | 123 | priya-nandakumar | Priya Nandakumar | Lead 05 | Detail Hawk | serious, cautious | `05-data-ops/profiles/priya-nandakumar/priya-nandakumar.md` | 124 125 ## 8. Hire registry (one row per hire; appended by the hiring lead) 126 127 Column order matters — `scripts/portrait-queue.sh` reads the *Persona path* and *Portrait* columns by position. `Portrait` ∈ `queued` · `done` · `failed` · `none`. 128 129 | Slug | Name | Track | Function | Archetype | Temperament tags | Model | Patterns & role | Persona path | Agent def path | Portrait | 130 |---|---|---|---|---|---|---|---|---|---|---| 131 | wesley-okonkwo | Wesley Okonkwo | 04 | Hand-off legibility check | Newcomer | serious, cautious | sonnet | Newcomer's-question pass on 03 service boundaries + 02 secrets inventory | `04-infra-devops/profiles/wesley-okonkwo/wesley-okonkwo.md` | `.claude/agents/wesley-okonkwo.md` | done | 132 | bree-sandoval | Bree Sandoval | 04 | Infra counterweight | Enthusiast | playful, bold | sonnet | Informal capability-case seat on containerization/CI/dev-loop | `04-infra-devops/profiles/bree-sandoval/bree-sandoval.md` | `.claude/agents/bree-sandoval.md` | done | 133 | callum-ferreira | Callum Ferreira | 04 | Independent scope guard | Minimalist | serious, cautious | sonnet | Informal scope-cut review across all five deliverables | `04-infra-devops/profiles/callum-ferreira/callum-ferreira.md` | `.claude/agents/callum-ferreira.md` | done | 134 | imogen-hale | Imogen Hale | 01 | Narrative counterpart / framing proponent | Storyteller | playful, bold | sonnet | Structured written debate: position (S2); rotating devil's advocate: objection alt (S5) | `01-product-industry-research-design/profiles/imogen-hale/imogen-hale.md` | `.claude/agents/imogen-hale.md` | done | 135 | desmond-okafor | Desmond Okafor | 01 | Claims auditor (lead's opposite) | Spreadsheet | serious, cautious | sonnet | Claims audit (S1); debate pre-synthesis fact check (S2); rotating devil's advocate: objection (S5) | `01-product-industry-research-design/profiles/desmond-okafor/desmond-okafor.md` | `.claude/agents/desmond-okafor.md` | done | 136 | tobias-lindqvist | Tobias Lindqvist | 01 | Framing rebuttal / standing objection | Designated Skeptic | serious, bold | sonnet | Structured written debate: rebuttal (S2); rotating devil's advocate: objection (S4) | `01-product-industry-research-design/profiles/tobias-lindqvist/tobias-lindqvist.md` | `.claude/agents/tobias-lindqvist.md` | done | 137 | oren-castellan | Oren Castellan | 03 | Code reviewer | Detail Hawk | serious, cautious | sonnet | Rotating devil's advocate (implementation calls); 2nd-pass reviewer on S3/S4 | `03-engineering-delivery/profiles/oren-castellan/oren-castellan.md` | `.claude/agents/oren-castellan.md` | done | 138 | nolan-reyes | Nolan Reyes | 03 | Ops/token-lifecycle sanity | Veteran | serious, cautious | sonnet | Three hats pessimist (Go layout); rotating devil's advocate participant | `03-engineering-delivery/profiles/nolan-reyes/nolan-reyes.md` | `.claude/agents/nolan-reyes.md` | done | 139 | marisol-ferran | Marisol Ferran | 03 | Tooling counterweight (Renata's opposite) | Enthusiast | playful, bold | sonnet | Three hats optimist (Go layout); counterweight on DAO driver-abstraction c … truncated (20 KB cap)
{
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/decisions/test-double-strategy.md"
}1 # Decision: Test-double strategy (S5) 2 3 Pattern: rotating devil's advocate, decision 3 of 3 (seat: Marisol Ferran). Proposer: Renata Cole. 4 5 ## Proposal 6 7 Hand-written interface fakes (not generated mocks) for DAO/handler tests — the `Repository` composite and its three sub-interfaces are small and stable enough that a fake struct with configurable fields/funcs per test is less overhead than adopting a codegen tool (`gomock`/`mockery`) for this scope. 8 9 ## Objections — Marisol Ferran (Enthusiast) 10 11 1. **Interface drift is silent with hand-written fakes.** A new interface method still compiles against a stale fake, which silently zero-values it instead of failing a test that should catch the gap. Generated mocks fail loudly or leave a visibly regenerated stub. Marisol's own mitigation: enforce fake regeneration in the same PR as any interface change, as a review checklist item — makes drift a procedural risk, not a tooling one. 12 2. **Call-count/ordering assertions are free with mocks, hand-rolled with fakes.** Conceded this is likely moot here — S2–S4's test cases are table-driven input/output, not call-sequencing assertions. 13 3. **Legibility to a reviewer** — conceded weak by Marisol herself: a 3-method, ~20-line fake is faster to read and trust than looking up `mockgen` syntax, for a project this size. 14 15 Marisol's own assessment: objection 3 doesn't hold at this scale; objections 1 and 2 are real at scale but this project may not have that scale. 16 17 ## Ruling (Renata Cole) 18 19 **Hand-written fakes stand** — objection 3 was conceded by its own author, and objection 2 doesn't apply given the table-driven test shape already committed to in S5. Objection 1 is real but has a cheap procedural fix, which is adopted rather than reaching for a codegen dependency to solve it. 20 21 Adopted: any change to `Repository` or its three sub-interfaces must update the corresponding hand-written fake in the same PR — added as a stated review-checklist item for S2/S5, not left as an assumed discipline. This is the review-time equivalent of what a generated mock would catch automatically, at zero added dependency cost. 22 23 ## Status 24 25 Ruled. No codegen/mocking library added to the module. Carries into S5 as a review-checklist line item. 26 27 --- 28 Model: sonnet (Marisol Ferran, objection seat; Renata Cole, lead, proposer + ruling). Turns consumed: 1 hire turn + lead synthesis. 29
{
"command": "for f in */decisions/*.md; do echo \"--- $f (lines: $(wc -l < \"$f\"))\"; tail -n 3 \"$f\" | grep -nE \"Model:|Turns\" || echo \" !! NO Model/Turns FOOTER IN LAST 3 LINES\"; done; echo; echo \"=== §8 hire rows ===\"; awk '/^## 8\\./,0' CASTING.md | grep -cE '^\\| [a-z-]+ \\| [A-Z]'; echo \"=== rows not done ===\"; awk '/^## 8\\./,0' CASTING.md | grep -E '^\\| [a-z-]+ \\| [A-Z]' | grep -vc '| done |'; echo \"=== agent defs ===\"; ls .claude/agents | wc -l; grep -L \"^model:\" .claude/agents/*.md; echo \"=== version labels for handoff-04-secrets ===\"; grep -rn \"handoff-04-secrets.md\\` v[0-9]\\|handoff-04-secrets.md v[0-9]\\|Status: \\*\\*v\" --include=*.md . | grep -v \"^./.git\"",
"description": "Check decision footers, registry row count, agent defs, and hand-off version labels"
}--- 01-product-industry-research-design/decisions/claims-audit.md (lines: 26)
!! NO Model/Turns FOOTER IN LAST 3 LINES
--- 01-product-industry-research-design/decisions/password-baseline-debate.md (lines: 43)
!! NO Model/Turns FOOTER IN LAST 3 LINES
--- 01-product-industry-research-design/decisions/rationale-cold-read.md (lines: 34)
!! NO Model/Turns FOOTER IN LAST 3 LINES
--- 02-ai-security-architecture/decisions/ai-workflow-claim-debate.md (lines: 36)
3:Model: sonnet (Felix Adebayo, Position A) / sonnet (Ingrid Solano, Position B) / sonnet (Marcus Ilori, lead, per-point synthesis). Turns consumed: 2 + lead (Position A, Position B/rebuttal, lead synthesis) — matches the 3-turns-plus-lead budget in `PLAN.md` §3.
--- 02-ai-security-architecture/decisions/connector-token-lifecycle-redblue.md (lines: 60)
3:Model: sonnet (Tomasz Wrede, red + rebuttal; Felix Adebayo, alternatives; Helena Marsh, blue) / sonnet (Marcus Ilori, lead, ruling and residual scoring). Turns consumed: 5 (red top-five, alternatives, blue, rebuttal, lead ruling) — matches the 4-turns-plus-lead budget in `PLAN.md` §3.
--- 02-ai-security-architecture/decisions/search-authz-scoping.md (lines: 45)
3:Model: sonnet (Helena Marsh, Proposer) / sonnet (Ingrid Solano, Skeptic) / sonnet (Marcus Ilori, lead, ruling). Turns consumed: 3 + lead (draft, objections, answer, ruling) — matches the 3-turns-plus-lead budget in `PLAN.md` §3.
--- 02-ai-security-architecture/decisions/second-review-security-docs.md (lines: 46)
3:Model: sonnet (Helena Marsh, Tomasz Wrede, Ingrid Solano — reviews) / sonnet (Marcus Ilori, lead — reassignment ruling, fixes applied). Turns consumed: 3 (one review turn per document; no rebuttal round needed since all findings were accepted outright) — matches the 2-turn budget in `PLAN.md` §3, one turn over due to the third document added by the reassignment.
--- 03-engineering-delivery/decisions/context-propagation.md (lines: 34)
3:Model: sonnet (Nolan Reyes, objection seat; Renata Cole, lead, proposer + ruling). Turns consumed: 1 hire turn + lead synthesis.
--- 03-engineering-delivery/decisions/error-semantics.md (lines: 39)
3:Model: sonnet (Oren Castellan, objection seat; Renata Cole, lead, proposer + ruling). Turns consumed: 1 hire turn + lead synthesis.
--- 03-engineering-delivery/decisions/go-layout-debate.md (lines: 114)
3:Model: sonnet (Marisol Ferran, optimist; Nolan Reyes, pessimist; Renata Cole, lead, pragmatist + ruling). Turns consumed: 2 hire turns + lead synthesis.
--- 03-engineering-delivery/decisions/test-double-strategy.md (lines: 28)
3:Model: sonnet (Marisol Ferran, objection seat; Renata Cole, lead, proposer + ruling). Turns consumed: 1 hire turn + lead synthesis.
--- 04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md (lines: 46)
3:Model: sonnet (Theo, ruling) / sonnet (Wesley Okonkwo, cold read). Turns consumed: 1.
--- 05-data-ops/decisions/handoff-legibility-05-03.md (lines: 77)
!! NO Model/Turns FOOTER IN LAST 3 LINES
--- 05-data-ops/decisions/migration-approach-objections.md (lines: 61)
!! NO Model/Turns FOOTER IN LAST 3 LINES
--- 05-data-ops/decisions/multi-db-abstraction.md (lines: 52)
!! NO Model/Turns FOOTER IN LAST 3 LINES
--- 05-data-ops/decisions/search-query-shape.md (lines: 74)
!! NO Model/Turns FOOTER IN LAST 3 LINES
=== §8 hire rows ===
18
=== rows not done ===
0
=== agent defs ===
18
=== version labels for handoff-04-secrets ===
(eval):1: no matches found: --include=*.md{
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/decisions/second-review-security-docs.md"
}1 # Decision: Independent Second Review of Security-Graded Docs (S8) 2 3 Pattern: second review, independence assigned per-document by authorship history rather than the original static roster in `PLAN.md` — see reassignment note below. Each reviewer covers a document they neither authored nor blue-teamed during Phase 2 execution. 4 5 ## Reassignment note 6 7 `PLAN.md`'s original S8 plan assigned Helena Marsh to review both `threat-model.md` and `api-auth-design.md`, and Ingrid Solano to review `connector-security.md`, on the assumption that neither had touched `api-auth-design.md`. That assumption broke during execution: Helena became S4's Proposer, authoring the finalized scope vocabulary and policy content in `api-auth-design.md`, and was also the blue seat in S3, authoring the controls in `connector-security.md`. Reviewing either would be an author reviewing their own work, not a second reviewer. Reassigned: **Helena → `threat-model.md` only** (never authored or blue-teamed); **Tomasz Wrede → `api-auth-design.md`** (no involvement in Phase 2 — his S3 work was the connector, not the search API); **Ingrid → `connector-security.md`** (unchanged — she never touched it). 8 9 ## Review: `threat-model.md` (Helena Marsh) 10 11 Four citation/precision fixes, one completeness note, all accepted: 12 13 1. NIST SP 800-63B was invoked by name for recommending Argon2id specifically, but 800-63B Rev. 3 (current) requires only "a suitable memory-hard KDF" without naming Argon2id — that specific naming is in the draft SP 800-63-4, not yet final. Fixed: claim softened to cite what 800-63B Rev. 3 actually requires, Argon2id credited to OWASP alone. 14 2. WebAuthn counter/credential-ID integrity was asserted with no citation. Fixed: W3C WebAuthn Level 2 §6.1.1 added. 15 3. The TLS 1.2+ floor was asserted with no citation. Fixed: NIST SP 800-52 Rev. 2 added. 16 4. Credential-stuffing mitigation presented account lockout as a peer option alongside rate limiting/backoff; NIST SP 800-63B §5.2.2 actively disfavors lockout as a self-inflicted denial-of-service vector. Fixed: lockout language removed, throttling/backoff/step-up stated as the preference with the citation. 17 5. **Completeness, not a defect:** the three-asset scope silently omitted this API's own issued JWTs. Fixed: one line stating that boundary explicitly as `api-auth-design.md`'s asset, not re-modeled here. 18 19 ## Review: `api-auth-design.md` (Tomasz Wrede) 20 21 Four findings, ranked, all accepted: 22 23 1. **High.** The cumulative distinct-record-touch counter (S4's fix for decomposition-style enumeration) was worded to exempt any call carrying a `reason_code`. `profile:read:any`'s policy hook already requires a `reason_code` on every call, so the exemption was unconditionally true for that scope — the counter that closed the decomposition gap for `profile:search` was a no-op for `profile:read:any`, the scope with the broadest blast radius (no referral constraint). Nobody in S4's adversarial pair was looking at this angle, since the counter itself was Ingrid's fix to a different objection (about `profile:search`). Fixed: the counter now applies to `read:any` regardless of `reason_code` presence. 24 2. **Medium.** No stated defense against JWT algorithm confusion at the verifier (`alg: none`, RS256→HS256 downgrade). Fixed: verifier must pin the expected algorithm and reject any other, cited to RFC 8725 §3.1. 25 3. **Medium-high.** No brute-force/lockout protection on the client-credentials token endpoint itself — every rate limit in the design protects the resource API after a token issues, none protect the endpoint where a caller obtains one. Fixed: per-`client_id`/per-IP failed-grant rate limiting and alerting added. 26 4. **Minor, consistency.** This API's own bearer-token replay has the identical accepted-risk profile as the connector's vendor token (S3), but unlike the connector, it was never named as such — silently assumed safe rather than stated. Fixed: named explicitly as an accepted risk, with the same reasoning (TTL bounds exposure, no PoP lever expected or required). 27 28 ## Review: `connector-security.md` (Ingrid Solano) 29 30 Two findings, both accepted, both outside the frame of the three S3 participants (who were entirely focused on custody of the vendor token, not on who may call the connector or what a vendor token authorizes): 31 32 1. **No authentication requirement on calls into the connector's own `/auth`/`/identity`.** §1–§4 protected the outbound vendor call exclusively; nothing stated who is allowed to reach the connector's own endpoints, which meant the rate-limiting control in §4 had no caller identity to bind "per calling-API-client" to. Fixed: new §5 requires the same OAuth2/JWT mechanism as the search API, with a distinct audience (`idp-connector-service`) and scope (`connector:identity-lookup`), restricted to internal service callers. 33 2. **Whether a vendor token authorizes a query for an arbitrary identity, or only the token-holder's own, was unaddressed.** The design's entire token-handling discipline (§1) governs custody, not authority. Fixed: new §6 states plainly, as an assumption rather than a resolved control, that this design assumes the vendor binds `/identity` queries to the authenticated principal — and names the consequence if a real vendor doesn't. 34 35 ## Downstream propagation 36 37 Both `api-auth-design.md` fixes affecting implementation (finding 1's counter scope, findings 2–4) and `connector-security.md`'s new §5 (connector auth requirement) were folded into `handoff-03-auth.md` (now v2) and relayed directly to Renata Cole (03), who confirmed she will build the connector's handler auth to the new requirement from the start rather than retrofitting. 38 39 ## Why this run mattered (for `ai-workflow-narrative.md`, S9) 40 41 Every one of these six findings (4 in threat-model, 4 in api-auth-design — wait, tally below) survived because the reviewer had zero authorial investment in the document's existing frame. The `api-auth-design.md` finding 1 is the clearest case: the counter's flaw was invisible to Helena (who wrote it) and Ingrid (whose objection it was answering) precisely because both were reasoning inside "does this satisfy the objection as stated," not "does this control actually bind for every scope it claims to cover." Tomasz, arriving with no stake in either the original proposal or its revision, checked the mechanism against all three scopes independently and found the gap in under one review pass. 42 43 **Tally, honestly counted (per S5's ruling that null results count too):** 4 + 4 + 2 = 10 findings across three documents, zero null reviews this time — every second-review seat found at least one real, accepted gap. This is a different (better) hit rate than S3's red/blue, where one of three top-ranked leaves (leaf 2 in S3) was conceded clean with no new finding. Both data points belong in S9's tally, not just the hits. 44 45 --- 46 Model: sonnet (Helena Marsh, Tomasz Wrede, Ingrid Solano — reviews) / sonnet (Marcus Ilori, lead — reassignment ruling, fixes applied). Turns consumed: 3 (one review turn per document; no rebuttal round needed since all findings were accepted outright) — matches the 2-turn budget in `PLAN.md` §3, one turn over due to the third document added by the reassignment. 47
{
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/decisions/ai-workflow-claim-debate.md"
}1 # Decision: Core Claim of `ai-workflow-narrative.md` — Structured Written Debate (S5)
2
3 Pattern: structured written debate, amended per `CASTING.md` §5 ⟨02⟩ — the synthesis must state which position was stronger on which point and why; preserving both positions is necessary, not a substitute for a ruling.
4
5 **The claim at issue:** does this project's multi-agent, persona-driven, debate-pattern workflow produce *better design* than one strong session working the same problem alone, or is it cost without evidence — since Anthropic's own cited 90.2% figure (research doc §3) is for research-task breadth, not design quality?
6
7 ## Position A — Felix Adebayo
8
9 The narrative undersells its own strongest evidence by citing only a borrowed research-breadth figure with no example of what this project's own structure bought in design quality, when a concrete one now exists. Two instances, produced inside this track:
10
11 1. **S3's alternatives seat.** Tomasz's red-seat pass produced five leaves each shaped like "add a control." Felix's seat existed to ask a structurally different question of the same artifact — not "is this control sufficient" but "does the thing it protects need to exist" — and produced two leaves deleted by removing the cache entirely rather than hardened. A single strong session would have to interrupt its own momentum to ask that question of itself, mid-stream, with nobody else in the room to surface it.
12 2. **Theo's Newcomer cold-read.** Caught two real gaps in `handoff-04-secrets.md` (TOTP feature-status ambiguity, a non-self-contained trust-boundary reference) that survived the document's own author's review, because an author rereading their own hand-off checks "does this say what I meant," not "does this stand alone for a stranger."
13
14 Explicitly declined to stretch: two data points from one track is not "multi-agent design beats solo design" as a general law. The defensible, narrower claim is: for *this specific pattern* — a seat whose brief is explicitly to hold a different optimization target than the seat before it — there are now two instances of it catching something a same-frame or same-author pass structurally could not, and zero instances yet of an extra seat producing nothing but turn cost. Flagged: if S8 comes back clean, that's the honest place the claim should soften, and the narrative should say so if it happens.
15
16 ## Position B — Ingrid Solano (rebuttal)
17
18 "Compared to what" is the whole objection. Both instances were produced by seats this project built specifically to catch these failure modes — Felix's alternatives seat exists in order to propose deleting leaves; Theo's Newcomer exists in order to catch what a same-author read misses. That a role staffed to do X sometimes does X is not evidence multi-agent structure produces better design in general; a single strong session working from an explicit checklist ("can this control's leaf be deleted instead," "get one cold read before shipping") could plausibly produce the same two catches without four personas and a debate protocol — until that comparison is run or estimated, the two are indistinguishable from these data points alone.
19
20 **Selection-effect point, the sharper version of the same objection:** the narrative cites the hits and doesn't count the misses in the same table. Tomasz's rebuttal conceded two of three points cleanly in S3 — a turn spent producing no new leaf, no design change, nothing citable as a catch. If a clean concession counts as "the pattern working as intended, no false positive," it belongs in the tally as a neutral result, not silently absent while the hits are cited by name. Honest accounting so far: 2 hits, 1 null.
21
22 **On citing S8's outcome:** it hasn't run. Citing its hypothetical result in either direction is the same overclaim this track's discipline exists to prevent, aimed at a future data point instead of a past one.
23
24 **What would change her mind:** a same-scope comparison (even informal) showing a single strong session with an explicit deletion-prompt and cold-read step failed to catch what these seats caught, or a third instance outside this track from a role with no structural reason to look.
25
26 ## Ruling (Marcus Ilori), per point
27
28 1. **Whether this evidence supports a general law that multi-agent design beats solo design.** Ingrid is stronger. n=2, both instances produced by roles engineered for exactly this outcome, no baseline comparison run or estimated. The narrative must not claim a general empirical law from this.
29 2. **Whether the narrative should report the null result (Tomasz's clean concession) alongside the hits.** Ingrid is stronger, and this is adopted outright — an honest tally counts 2 hits, 1 null, not 2 hits with the null silently omitted. This is a completeness fix, not a debatable judgment call.
30 3. **Whether the mechanism-level argument — a role built to hold a different optimization target, or a reader with zero authorial investment, structurally catches what self-review tends not to — is itself defensible.** Felix is stronger here, and this survives Ingrid's critique because it is not the statistical claim Ingrid is rebutting. "A second reviewer with a different brief catches errors the first reviewer's own frame makes invisible to them" is a structural/mechanism argument (adjacent to well-established human-factors reasoning about independent review, not something this project needs n>2 to assert) rather than an empirical generalization requiring a sample size. The two instances are evidence the mechanism operated here, not evidence of a population-level effect size.
31 4. **Whether to cite S8's unrun outcome.** Ingrid is stronger, outright. Don't.
32
33 **What `ai-workflow-narrative.md` (S9) states as a result:** the defensible claim is the mechanism-level one (point 3), evidenced honestly by the S3 and S7 instances and the S3 null result counted alongside them (point 2), explicitly disclaiming any general statistical law about multi-agent versus solo design quality (point 1), and not citing S8 until it has actually run (point 4). This is Ingrid's discipline applied to Felix's real evidence — not a rejection of either seat's contribution.
34
35 ---
36 Model: sonnet (Felix Adebayo, Position A) / sonnet (Ingrid Solano, Position B) / sonnet (Marcus Ilori, lead, per-point synthesis). Turns consumed: 2 + lead (Position A, Position B/rebuttal, lead synthesis) — matches the 3-turns-plus-lead budget in `PLAN.md` §3.
37 {
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md"
}1 # Newcomer's-question pass: 03 service boundaries + 02 secrets hand-off 2 3 Pattern: Newcomer's question (`CASTING.md` §5). Reader: Wesley Okonkwo, cold read, 1 turn. 4 5 ## Targets 6 7 1. `../PLANNING.md` — Service boundaries section (owned by 03/Renata Cole). 8 2. `../02-ai-security-architecture/handoff-04-secrets.md` v1 (owned by 02/Marcus Ilori). 9 10 ## Findings (verbatim list, Wesley's turn) 11 12 **Service boundaries (`PLANNING.md`):** 13 1. `Repository` interface method set marked "not yet stable" — Wesley can design against the factory signature only, not the interface shape. Not a defect, just confirms 04 can't start DAO-adjacent work yet. 14 2. "12-factor" used undefined. 15 16 **`handoff-04-secrets.md`:** 17 3. Row 2/7 — "envelope encryption", KEK vs. DEK relationship; DEK itself never defined (only "re-wrapping DEKs" in the rotation column). 18 4. Row 2 — `kid` claim undefined. 19 5. Row 2/3 — RS256/ES256, JWKS assumed familiar. 20 6. Row 4 — Argon2id, OAuth2 client-credentials grant assumed familiar. 21 7. Row 7 — "if either feature exists" reads as TOTP support being undecided, not confirmed. Wesley can't tell if he's building around a maybe or a yes. 22 8. Row 7a — "workload-identity-based" assumed K8s/cloud vocabulary undefined in-document. 23 9. Assumption 3 — "external operator agent" never named in the hand-off itself; only resolvable from context outside the document (04's own `PLAN.md` names it as `amber-kubernetes`). 24 10. Kubernetes-specific requirements, last bullet — "incident-review role" named but who/what holds it or how it's provisioned is unstated. 25 26 ## Disposition 27 28 Items 1-2 belong to 03's document; relayed to Renata (03) to gloss or leave as-is at her discretion — informational, not blocking 04. 29 30 Items 3-10 belong to 02's document; relayed to Marcus (02) since the Newcomer's-question pattern has the author fix, not the receiver. Item 9 is the one substantive flag: the hand-off's own trust-boundary language (assumption 3) should name the operator actor rather than relying on a cross-track reference to 04's `PLAN.md` — a hand-off shouldn't require reading a second track's file to parse its own assumptions. Item 7 (TOTP: maybe vs. yes) is the second-most substantive — 04 needs to know whether to design KEK delivery around a real feature or a hypothetical before finalizing the secrets-delivery deliverable. 31 32 Everything else (jargon glosses: KEK/DEK, kid, RS256/ES256, JWKS, Argon2id, client-credentials, workload identity) is cosmetic — the underlying requirement is clear to Theo and doesn't block drafting; flagged to Marcus as optional polish, not gating. 33 34 No fix applied in this track — hand-off content isn't 04's to edit. 35 36 **Resolved by Marcus (02), v2 of `handoff-04-secrets.md`:** 37 - Item 7 (TOTP): neither TOTP nor connector token-caching is a committed feature. TOTP is a supported *value* in 05's `user_credential.method` lookup, not a planned implementation. Token-caching is under live debate in 02's S3 (connector token-lifecycle red/blue); current direction (Felix's alternatives turn) is "no cache by default." Row 7 is now an explicit named contingency, not a live requirement — 04 provisions it only if either feature materializes. 02 will follow up once S3 lands. 38 - Item 9 (operator naming): assumption 3 now names the operator inline in the hand-off itself rather than requiring a read of 04's `PLAN.md`. 39 - Items 3-6, 8 (jargon glosses): left as-is, deliberately — those terms are already defined/sourced in `api-auth-design.md`/`threat-model.md`, which the hand-off already points to; re-glossing here would duplicate content per the project's own no-duplication rule. Accepted by 04 — not gating. 40 41 03's items (1-2) also resolved: "12-factor" glossed in `PLANNING.md`; the Repository-interface note was intentional (genuinely unstable, blocked on 05) and needed no fix. 42 43 04 proceeds on `handoff-04-secrets.md` v2, still gated on 05's migration approach before drafting any of the five deliverables. 44 45 --- 46 Model: sonnet (Theo, ruling) / sonnet (Wesley Okonkwo, cold read). Turns consumed: 1. 47
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/decisions/multi-db-abstraction.md"
}1 # Decision: the multi-database abstraction shape
2
3 Pattern: **three hats** (`../../CASTING.md` §5). Decision owner: Priya Nandakumar, 05-data-ops. Story: S3 in `../PLAN.md`.
4
5 **Question.** Per-driver implementations behind one interface (what `../multi-db-strategy.md` already recommends) vs. a single implementation with dialect branches vs. codegen (sqlc).
6
7 **Why it was run at all.** The existing recommendation was reached by one agent reading sources, and codegen had been name-checked in this track's documents without ever being argued. A recommendation nobody has attacked is an assumption wearing a conclusion's clothes. The three hats wrote in parallel and none saw another's position before this synthesis.
8
9 ## Ruling
10
11 **Confirm per-driver implementations behind one interface — with the justification corrected, and conditional on a conformance suite.**
12
13 Three things changed in reaching it, and the second is the one that matters most:
14
15 1. **Codegen is not a competitor and the three-way framing was wrong.** sqlc generates a `Querier` per engine from per-engine SQL; those Queriers are distinct types satisfying no common interface until someone hand-writes one. Codegen therefore *fills* the boxes that per-driver implementations draw — it is orthogonal to the structural question, not an answer to it. The real choice was always two-way. **sqlc is 03's implementation preference to take or leave**, with one caveat from the optimist seat worth passing on: dynamic optional filters are sqlc's weakest case, and `Search()` — the flagship query of this contract — is exactly that shape. Generated CRUD plus one hand-written query per package buys type safety on the easy half and costs a toolchain.
16
17 2. **The document's stated reason for rejecting dialect branches is wrong, and is corrected here.** `../multi-db-strategy.md` rejects them on "`if backend == "sqlite"` scattered through query construction." The pragmatist seat counted: of the six concerns in that document's own dialect table, exactly **two** survive as real conditionals — app-side PK generation for SQLite, and fuzzy name search (`pg_trgm` vs `LIKE`). Placeholders are the driver's problem, upsert syntax is identical across all three, JSON is unused, booleans map at the boundary. "Scattered" describes a schema we do not have. It is two `if`s.
18
19 So the two options cost roughly the same, and **the choice between them is made on reviewability, not on cost.** That is a legitimate tiebreak — two parallel files a reviewer can diff read better than one file with two apologetic conditionals, and this submission is graded on design reasoning — but it must be stated as what it is. A reviewer who checks the count finds the discrepancy in ten seconds; a document that pre-empts that reads as honest, one that doesn't reads as padded. Ruling for the cheaper-to-review option while *claiming* it is the cheaper option would be the kind of small dishonesty this whole exercise is supposed to catch.
20
21 3. **The real risk of the chosen option is silent behavioural divergence, not duplicated code.** The interface guarantees both packages compile. Nothing guarantees they behave alike, and the failure mode is invisible: no error, just different results. The pessimist seat produced a concrete, previously undocumented instance — see below. **The mitigation is not code review. It is one conformance suite, run against every backend.** Without it, per-driver implementations are the *most* expensive option rather than the cheapest, and the ruling is conditional on it existing.
22
23 ## Consequent corrections to `../multi-db-strategy.md`
24
25 Both are errors in that document found by this run, recorded here so the fix is traceable rather than silent:
26
27 - **The SQLite search parity gap is a correctness gap, not a performance gap.** The document says `Search()` "behaves identically from the caller's side" on SQLite, with only a fallback to `LIKE '%term%'`. That is false. SQLite's `LIKE` is case-insensitive for ASCII by default; PostgreSQL's is case-sensitive. The same `Search("smith")` returns **different row sets** per backend, with no error anywhere. The word "identically" was hiding a behavioural difference. S1 must state the gap as one of result semantics, and S2 must decide whether the two backends are brought into agreement or the divergence is documented as accepted.
28 - **The rejection reasoning for dialect branches**, per §2 of the ruling above.
29
30 ## Requirement flowing to 03
31
32 **A single conformance suite, written once against the interface, executed against every backend implementation.** Not per-package unit tests — the same assertions run twice, so that a behavioural difference fails a test instead of reaching a user. The `LIKE` case-sensitivity divergence above is the worked example of what it is for, and it is the kind of defect that cannot be caught by reading either implementation in isolation, because each is correct on its own terms.
33
34 Mechanism is Renata's (`../../03-engineering-delivery/`); the requirement is this track's, because it is the condition under which this ruling holds. Carried into S1 as a named out-of-scope-for-redesign item.
35
36 ## Out of scope for this ruling
37
38 Whether one `postgres` package can honestly serve both PostgreSQL and CockroachDB is a **separate decision**, deliberately excluded from this run so three hats would not argue about evidence none of them had. It was raised by the Systems Cartographer seat at check-in, researched under `../../CASTING.md` §3 by the lead, and answered in `../research-cockroachdb-postgres-semantics.md`: yes, but only with an engine-aware retry-and-error seam. That finding constrains what goes *inside* the `postgres` package; it does not disturb the structural split ruled on here.
39
40 ## Positions as written
41
42 Preserved rather than summarized away, per the pattern protocol. All three are reproduced in full in the hires' returned results; the load-bearing content of each:
43
44 - **Optimist (Anders Vogel, Systems Cartographer).** Confirmed per-driver by argument. The boundary that matters runs between the service layer and anything that knows a dialect; dialect branches move engine-awareness from one place (startup wiring) into every query that needs one, making the SQLite path "negative space inside the Postgres path." Established the codegen-is-orthogonal point that reframed the whole run. Noted unprompted that his case rests on the two engines that exist and needs no third — his stated blind spot, checked before it had to be named.
45 - **Pessimist (Yusuf Karadag, Detail Hawk, substituting for the Veteran seat).** Each option's failure stated as the day it shows up in production. Found the `LIKE` case-sensitivity divergence. Argued that dialect branches fail on *test coverage*, not tidiness — every conditional is a branch CI runs on exactly one backend — which is a better objection than the document's own. Flagged the CockroachDB question as an open question rather than a finding, as instructed.
46 - **Pragmatist (Beatriz Achterberg, Spreadsheet).** Counted files and branch points before forming an opinion. Produced the two-conditionals count that overturned the document's rejection reasoning, and named the consequence plainly: the choice is aesthetics and reviewability, so say so. Rejected codegen as the only option with a genuinely different cost profile — a dependency, a generate step, two config files, two query sets, and generated code in the repo, for a payoff (compile-time query verification) that is invisible in a submission nobody runs.
47
48 Where the seats disagreed: the optimist treated per-driver as clearly cheapest; the pragmatist showed it is not cheaper, only more reviewable. **The pragmatist was right on the facts and the optimist right on the conclusion** — which is the outcome that justifies having run the pattern, since a single agent would have produced the right answer with the wrong reason and nobody would have checked.
49
50 ---
51
52 *Pattern footer (`../../CASTING.md` §5 ⟨02⟩). Seats: optimist Anders Vogel — sonnet; pessimist Yusuf Karadag — sonnet; pragmatist Beatriz Achterberg — sonnet; synthesis and ruling Priya Nandakumar — Claude Opus 5. Hire turns consumed: 3 (one per seat, parallel). Supporting deep-research pass: 1 sonnet research subagent, authorized and framed by the lead, output at `../research-cockroachdb-postgres-semantics.md`. Total: 4 hire-equivalent turns.*
53 {
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/decisions/search-query-shape.md"
}1 # Decision: the `Search()` query shape
2
3 Pattern: **adversarial pair** (`../../CASTING.md` §5, named for this decision by name). Decision owner: Priya Nandakumar, 05-data-ops. Story: S2 in `../PLAN.md`. Feeds: S1.
4
5 **Question.** The `ProfileQuery` and result shape for `ProfileRepository.Search()` — field set, per-field match semantics, filter combination, pagination, sort, what `total` counts, and how the SQLite parity gap surfaces to a caller.
6
7 **Why it was worth three turns.** `Search()` is the flagship query of this contract and the only genuinely open design question in it. Every other DAO method is settled by the assignment text. This one had the most ways to be quietly wrong, and eight of them were found.
8
9 ## Ruling: adopt the revised proposal
10
11 Proposer: Anders Vogel. Skeptic: Yusuf Karadag, eight numbered objections. **All eight accepted and revised; none declined.** That is an unusually clean sweep, and it is a fact about the objections rather than about the proposer — six of the eight identified something the proposal asserted but did not enforce.
12
13 ```go
14 type ProfileQuery struct {
15 Name *string // optional: case-folded substring match, caller input escaped
16 Phone *string // optional: exact match on the E.164-normalized value
17 Region *string // optional: case-folded exact match
18 Country *string // optional: exact match, ISO 3166-1 alpha-2, upper-cased by the DAO
19 Limit int // 1..100; 0 means "use default 50", deliberately
20 Offset int // 0..10_000
21 }
22
23 Search(ctx context.Context, q ProfileQuery) (results []UserProfile, total int, err error)
24 ```
25
26 **Settled semantics:**
27
28 - **Absent vs. empty.** `nil` = absent (don't filter). Non-nil but empty-after-trim is a caller bug → `ErrInvalidQuery`. Coercing `""` to "match everything" is how a full-table scan reaches production.
29 - **Combination.** AND across present filters. No OR, no negation.
30 - **Empty query.** Legal, returns everything paginated. Whether a caller *may* do that is authorization policy owned by `../../02-ai-security-architecture/`; the DAO is the wrong layer to invent one, and the pagination ceiling is protection, not a policy veto.
31 - **Case folding.** `LOWER(column) LIKE LOWER(pattern)`, applied explicitly in both implementations. Residual accepted gap: PostgreSQL's `lower()` is locale-aware, SQLite's is ASCII-only, so non-ASCII folding still diverges. Narrow and named, unlike the ASCII divergence it replaces.
32 - **Escaping.** The DAO escapes `%`, `_` and `\` in the caller's term and emits an explicit `ESCAPE '\'` on **both** backends, rather than relying on PostgreSQL's default and SQLite's absence of one.
33 - **Collation and sort.** `ORDER BY LOWER(name) COLLATE "C" ASC, id ASC`, fixed, not caller-configurable, with collation named in DDL as well — `COLLATE "C"` on PostgreSQL, `BINARY` on SQLite. Stated cost, not hidden: `C` is byte order, so non-English names sort in an order a human would call wrong. **Identical-across-backends is chosen over locale-correct**, because a POC that returns different page-1 rows per backend is broken in a way that a POC sorting diacritics oddly is not.
34 - **Trigram index is an accelerator, never a ranker.** It accelerates the same `LIKE` predicate; no `similarity()` scoring, no rank ordering. This is what keeps the backends' row sets and order identical for ASCII input and confines the parity gap to speed. Adding ranking re-opens this decision — it does not get added quietly inside the `postgres` package.
35 - **Index.** Expression index `GIN (LOWER(name) gin_trgm_ops)`, not the bare column. **The predicate expression must match the index expression textually**, so `LOWER(name) LIKE ?` is the only permitted form; a future `name ILIKE ?` silently loses the index.
36 - **Pagination.** Limit/offset. Max page 100, default 50, offset ceiling 10 000. `Limit < 0` or `> 100`, and `Offset < 0` or `> 10_000`, return `ErrInvalidQuery`. Keyset rejected as designing for an uncommitted dataset size. Stated cost: offset pages are unstable under concurrent inserts — a row can shift or repeat across pages.
37 - **`total`.** Exact count of all matching rows, ignoring limit/offset, via a second `COUNT(*)` in no wrapping transaction, so it may disagree with the page by a row under concurrent writes. **`total` is a magnitude, not a promise of reachability** — it can exceed what the offset ceiling allows a caller to page to.
38 - **`Country`.** `CHECK (country = upper(country) AND length(country) = 2)` plus write-side normalization in both backends. SQLite's `upper()` is ASCII-only — sufficient for A–Z alpha-2 codes, written down so nobody assumes it generalizes.
39 - **Out of scope, stated rather than left silent:** searching for *absent* address values. `nil` means don't filter and `""` is an error, so no legal query reaches rows with a NULL `region`. Callers needing that list and filter client-side. This matters more than it sounds — partial addresses from `/identity` are the normal case for `idp_cache` rows, not an edge case.
40 - **Transactions.** `Search()` is read-only, two independent statements, no explicit transaction, so it never encounters a client-retryable `40001`. The CockroachDB retry seam (`../research-cockroachdb-postgres-semantics.md`) is scoped to `Update()`, which is S1's problem.
41 - **Sentinels.** `ErrInvalidQuery` joins the S1 set, triggers enumerated: empty-after-trim filter, `Limit` out of range, `Offset` negative or above ceiling, `Country` not two characters.
42
43 ## The general principle — the most valuable output of this run
44
45 Objections 1, 2, 3 and 8 are one failure repeated: **a guarantee asserted at the interface with no enforcement site in the schema.** Interface prose enforces nothing.
46
47 **Adopted into S1 as a requirement.** Every caller-visible guarantee in the contract carries a named enforcement site — a DDL constraint, an index definition, a normalization step, or a conformance test. *A guarantee with no site listed is not a guarantee, it is a hope.* S1 will carry this as a table, so the ninth instance is caught by the table rather than by a reviewer.
48
49 This generalizes the `LIKE` finding from S3 and both of this run's worst defects, and it is worth more to 03 than the eight individual patches are.
50
51 ## Lead's addition at synthesis
52
53 One point neither seat raised, added on my own authority rather than routed back for another turn: **no index supports the ordering.** A GIN trigram index cannot serve `ORDER BY`, so every `Search()` sorts its filtered set. Acceptable at this scale and for this POC, and explicitly *not* worth adding a second B-tree on `LOWER(name)` to fix — but it is a caller-visible performance characteristic and therefore, by the principle just adopted, needs its enforcement-site row to read "none — accepted." A guarantee we are choosing not to make still gets written down.
54
55 ## Objections and their disposition
56
57 All eight objections, each closing with an explicit "what would change my mind" as `../../CASTING.md` §5 ⟨02⟩ requires, and all eight answered in writing. Full text in both hires' returned results. Disposition:
58
59 | # | Objection | Disposition |
60 |---|---|---|
61 | 1 | `ORDER BY name` differs per backend (PG collation vs SQLite `BINARY` byte order); `id` tiebreak makes order total, not identical | **Accepted** — collation named in DDL and `ORDER BY` |
62 | 2 | `LOWER(name) LIKE` cannot use a GIN index on the bare column; accelerator accelerates nothing | **Accepted** — expression index; predicate must match textually |
63 | 3 | Caller input unescaped in `LIKE`; escape character differs across engines | **Accepted** — DAO escapes, explicit `ESCAPE '\'` both backends |
64 | 4 | No legal query can reach rows with a missing `region` | **Accepted as framed** — the objection was to the silence, not the choice; one line added |
65 | 5 | `total` can report a count the offset ceiling forbids reaching | **Accepted** — `total` restated as a magnitude |
66 | 6 | `Limit int` collapses "unspecified" and "zero"; negatives unaddressed | **Accepted** — validation rule stated; `int` retained over `*int` because `0` is not a meaningful page size and is therefore free as a sentinel, whereas `""` is a meaningful string, which is why filters need pointers |
67 | 7 | `ErrInvalidQuery` not in S1's sentinel set | **Accepted** — added with triggers |
68 | 8 | `Country` upper-cased into the query but not into the table | **Accepted** — `CHECK` constraint plus write-side normalization |
69
70 Objection 2 is the one I was independently watching for and deliberately did not feed to the Skeptic seat, to keep the pattern's integrity. He reached it unaided, which is evidence the seat is doing real work rather than confirming the lead's prior.
71
72 ---
73
74 *Pattern footer (`../../CASTING.md` §5 ⟨02⟩). Seats: Proposer Anders Vogel — sonnet, 2 turns (proposal, answers); Skeptic Yusuf Karadag — sonnet, 1 turn (eight numbered objections); ruling Priya Nandakumar — Claude Opus 5. Hire turns consumed: 3. Objections raised: 8. Accepted: 8. Declined: 0.*
75 {
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/decisions/migration-approach-objections.md"
}1 # Decision: objection turn on the migration approach 2 3 Pattern: **rotating devil's advocate** (`../../CASTING.md` §5 — the cheapest pattern in the catalog, meant to be run often). Objection seat: Beatriz Achterberg (Spreadsheet). Decision owner: Priya Nandakumar. Story: S4 in `../PLAN.md`. Subject: `../migration-approach.md`, the 05→04 hand-off. 4 5 Six objections raised, **six accepted**. One of them overturned the reasoning behind the document's central requirement. 6 7 ## 1. R2's conclusion no longer followed from its own corrected premise — accepted 8 9 The strongest objection, and the one I would have shipped without. 10 11 R2 requires that exactly one migration runner execute at a time, enforced by the orchestration platform rather than the database. The original argument was: "a locking strategy that only works on one of three backends is not a strategy." That was true of the premise as first written — that CockroachDB has no advisory locks at all. **But I had already corrected that premise**, in `../research-cockroachdb-postgres-semantics.md` §5: transaction-scoped `pg_advisory_xact_lock` *does* work on CockroachDB. Session-scoped locks error, and the `pg_try_*` variants silently no-op. 12 13 The objection: with the corrected facts, a transaction-scoped lock works on **both** backends that can actually have replicas — SQLite being a single-file local backend where two concurrent migration runners is not a scenario that exists. I had corrected the premise and left the conclusion resting on the old one, which is a worse failure than never correcting it: the document read as though it were reasoning from evidence it had already superseded. 14 15 **Ruling: the requirement stands; the reasoning is replaced.** A transaction-scoped advisory lock is released when its transaction ends, and goose applies each migration file in its own transaction — so the lock would be released *between files*, letting a second runner interleave mid-run. That is exactly the failure it was meant to prevent. Session-scoped locks would span the whole run, and those are the ones CockroachDB does not support. **The database can offer the right scope or the right engine coverage, never both.** 16 17 The objection anticipated that a good reason existed and said so explicitly — "*Closes if:* you show the transaction-scoped lock is impractical for a different reason… That is a good reason. It is not the reason written down." That is the distinction between an objection and a complaint, and it is why this seat is worth one turn per decision. 18 19 **This is the third time in this track that a hire has left a conclusion standing and destroyed the reasoning under it** (after the S3 dialect-branch count and the S2 error-translation rule). Three times is a pattern rather than three incidents: conclusions here have generally been right, and the arguments offered for them have generally been the first plausible one to hand rather than the true one. 20 21 ## 2. The cheapest backstop was never examined — accepted 22 23 The document asserted the database could not help, without examining the one database-level mechanism goose itself creates: its version table, the single artifact all three backends share. A unique constraint on `version_id` would make a second concurrent runner fail on insert — portable, free, no platform feature required. 24 25 **Ruling: accepted, and recorded with its gap visible.** Whether goose's default version table already carries that constraint is **unverified**, and the document now says so rather than asserting either way. The actionable form does not depend on the answer: add a unique index on `version_id` in the first migration, which costs nothing if goose already does it. It is a backstop, not a replacement for R2 — it converts a silent interleave into a loud failure, which is the property worth buying. 26 27 Leaving this as an explicit "unverified" rather than resolving it was deliberate. The objection's own framing — an empty cell is a question, not a gap to paper over — applies to the author as much as to the tables she wrote. 28 29 ## 3. The tool-independence claim had a counterexample in its own document — accepted 30 31 `migration-approach.md` claimed "nothing in §3–§6 changes if the tool changes." But §2's two-invocation mechanism uses goose's `-table` flag to keep two version sequences apart; swapping to golang-migrate means re-deriving it. Minor in effect, but it is the claim's only load-bearing use. **Ruling: accepted; the exception is now named inline.** An overclaim in a document whose entire argument is about not overclaiming would have been a poor advertisement for the rest of it. 32 33 ## 4. R3 checked one version number where §2 creates two — accepted 34 35 Fail-closed said the service compares "the applied version" against the version it was built against. §2 creates **two** independent sequences — `shared/` and the backend directory. As written, a service could pass the check with `shared/` current and the `pg_trgm` index unapplied: a passing check that proves nothing, which is worse than no check. 36 37 **Ruling: accepted.** R3 now requires comparing both, and knowing which backend directory applies to the engine it is connected to. 38 39 ## 5. `goose fix` had no stated cutoff — accepted 40 41 The document said to renumber "before anything is tagged." The real constraint is earlier and different: renumbering after migrations have been applied anywhere persistent rewrites history the version table already records. **Ruling: accepted** — it is a pre-first-application step, not a tidy-up before tagging. 42 43 ## 6. A gap the objector's own other deliverable created — accepted 44 45 `deletion_log`, introduced by the retention mechanism in `../pii-governance.md` (S5), is schema, and §2's `shared/` contents list did not include it. Caught by the hire who had written the table that created the gap, in the course of attacking a different document. 46 47 **Ruling: accepted;** `shared/` now lists it. Worth recording because it is the argument for running the objection seat *after* the sibling deliverable rather than in parallel with it — a cross-deliverable inconsistency is invisible to anyone holding only one of the two. 48 49 ## Also recorded: an unmodelled area, flagged by the objector against her own blind spot 50 51 Not an objection to this document, but raised alongside it and worth keeping: **subject-deletion requests reach `user_profile` and its cascade, and do not reach derived data** — logs, metrics, connector traces that may have incidentally captured PII in transit. That data has no `source` column, no retention clock, and often no key to select on. 52 53 Her framing, which I am adopting verbatim into `../pii-governance.md`: *"I have not modelled it. I would rather say so than let its absence read as a decision that it doesn't exist."* It sits across 02 (never-log list) and 04 (log retention), and the cheapest control is upstream of this track entirely — if PII never enters a log there is nothing to delete from it. 54 55 ## Outcome 56 57 `../migration-approach.md` is **final** and has gone to 04. 58 59 --- 60 61 *Pattern footer (`../../CASTING.md` §5 ⟨02⟩). Seat: objection-writer Beatriz Achterberg — sonnet, 1 turn (delivered alongside the S5 Table B tail). Rulings and revisions: Priya Nandakumar — Claude Opus 5. Hire turns consumed: 1. Objections raised: 6. Accepted: 6. Declined: 0. Cheapest pattern in the catalog; it overturned the reasoning behind the document's central requirement for one turn.* 62
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/research-cockroachdb-postgres-semantics.md"
}1 # Research: CockroachDB vs PostgreSQL — Transaction and Error Semantics 2 3 Owner: 05-data-ops. Authorized and run by the track lead (Priya Nandakumar) per `../CASTING.md` §3 "Deep research — leads only"; executed as a single scoped sonnet research pass, synthesized here. 4 5 **Why this exists.** `./multi-db-strategy.md` claims one Go `postgres` package can serve both PostgreSQL and CockroachDB. Anders Vogel (Systems Cartographer) flagged in his first check-in that the doc defends that claim on wire protocol and SQL surface only — "the cheap half of the argument" — and that the interaction surface worth mapping is transaction and error semantics, where the two engines are least alike. He was right, and the existing claim does not survive unqualified. This pass exists to replace an assumption with evidence before it reaches 03 as a contract. 6 7 Every substantive claim carries a source URL. Vendor pages are labeled. The "could not verify" list at the end is deliberate and is not padding. 8 9 ## Verdict 10 11 **One Go package can serve both engines — but not as a thin PostgreSQL implementation pointed at a second connection string.** It must carry an explicit engine-aware retry-and-error-translation seam. The divergence is narrow and nameable rather than architectural, which is why the two-package split in `./multi-db-strategy.md` survives; what does not survive is the sentence implying the two engines are interchangeable behind it. 12 13 ## 1. Retryable transaction errors — the material divergence 14 15 CockroachDB defaults to SERIALIZABLE and returns SQLSTATE `40001` on serialization conflicts. PostgreSQL at its default READ COMMITTED **never** emits `40001`; it only does so at REPEATABLE READ or SERIALIZABLE ([PostgreSQL, Transaction Isolation](https://www.postgresql.org/docs/current/transaction-iso.html); [PostgreSQL, Serialization Failure Handling](https://www.postgresql.org/docs/current/mvcc-serialization-failure-handling.html)). 16 17 CockroachDB retries internally where it can, but there is a hard limit: once a multi-statement transaction spans more than one round trip, or once results have begun streaming to the client (or exceed `sql.defaults.results_buffer.size`), server-side retry becomes impossible and **the client receives `40001` and must retry itself** ([Cockroach Labs, Transaction Retry Error Reference](https://docs.cockroachlabs.com/docs/stable/transaction-retry-error-reference) — vendor documentation, but this is the vendor documenting a requirement on its own clients, which is the strongest form this kind of claim takes). 18 19 Cockroach Labs prescribes a client-side `SAVEPOINT cockroach_restart` retry loop ([Advanced Client-Side Transaction Retries](https://docs.cockroachlabs.com/docs/stable/advanced-client-side-transaction-retries)) and ships `crdb.ExecuteTx` to wrap it — 50 retries by default, configurable ([pkg.go.dev, cockroachdb/cockroach-go/crdb](https://pkg.go.dev/github.com/cockroachdb/cockroach-go/crdb)). 20 21 **The load-bearing inference:** `crdb.ExecuteTx` exists *because* plain `database/sql` is insufficient for CockroachDB under SERIALIZABLE. A default-configuration PostgreSQL DAO needs no equivalent. A shared implementation that omits the wrapper is not "compatible with both" — it is correct on PostgreSQL and intermittently wrong on CockroachDB under contention, which is the worst failure shape available: invisible in development, load-dependent in production. 22 23 ## 2. Isolation levels 24 25 | | PostgreSQL | CockroachDB | 26 |---|---|---| 27 | Supported | READ COMMITTED, REPEATABLE READ, SERIALIZABLE | READ COMMITTED, SERIALIZABLE | 28 | Default | READ COMMITTED | SERIALIZABLE | 29 30 CockroachDB's READ COMMITTED has been GA since 24.1 ([Read Committed Transactions](https://docs.cockroachlabs.com/docs/v26.2/read-committed)). Running the CockroachDB backend at READ COMMITTED does change the answer to §1: under it CockroachDB "never returns RETRY_SERIALIZABLE errors," and the retry loop stops being mandatory for ordinary contention — though transactions can still abort on deadlocks, constraint violations, and rare streaming cases. 31 32 **A trap worth naming:** Cockroach's READ COMMITTED is documented as *stronger* than PostgreSQL's — it prevents anomalies within a single statement. So even when the isolation level name matches, the semantics do not. Matching the name is not matching the behaviour, which is the same error the original compatibility claim made one level up. 33 34 ## 3. Constraint-violation error codes — aligned, with a sharp edge 35 36 Both engines use `23505` (unique_violation) and `23503` (foreign_key_violation); CockroachDB implements the PostgreSQL error-code table (`pkg/sql/pgwire/pgcode/codes.go` in cockroachdb/cockroach). 37 38 But [cockroachdb/cockroach#42858](https://github.com/cockroachdb/cockroach/issues/42858) documents a real historical inconsistency — a duplicate FK *constraint definition* returned `23503` in one CockroachDB version, `42830` in another, against `42710` on PostgreSQL. Closed by PR #43210, but it establishes that constraint-related SQLSTATEs have not always agreed across versions. [cockroachdb/cockroach#16196](https://github.com/cockroachdb/cockroach/issues/16196) records divergence in FK-violation error reporting. Error *text* and structure are not guaranteed portable either: PostgreSQL embeds the constraint name and a `DETAIL:` line naming the offending key. 39 40 **Direct consequence for the S1 contract, and the reason this section exists:** the DAO translates driver errors into the shared sentinel set (`ErrDuplicateUsername`, `ErrInvalidMethod`) **by SQLSTATE code only — never by matching message text or constraint-name substrings**, and the CockroachDB target version is pinned. String-matching an error message is fragile against one engine and reckless against two. 41 42 ## 4. Go drivers 43 44 `pgx` and `lib/pq` work against CockroachDB largely unchanged for basic CRUD, since CockroachDB implements the PostgreSQL wire protocol — this is [CockroachDB's own compatibility page](https://docs.cockroachlabs.com/docs/stable/postgresql-compatibility), **vendor-asserted and cited here for what it claims, not as proof it holds**. That same page states plainly that CockroachDB is not a drop-in PostgreSQL replacement. 45 46 The existence of `cockroachdb/cockroach-go` (and `crdbpgx` for pgx-native pools) is the more informative evidence: a vendor does not ship a transaction wrapper for its own database unless the standard client path is insufficient. 47 48 ## 5. Other divergences that leak into a shared implementation 49 50 All from CockroachDB's [PostgreSQL Compatibility](https://docs.cockroachlabs.com/docs/stable/postgresql-compatibility) and [Known Limitations](https://www.cockroachlabs.com/docs/stable/known-limitations) pages (vendor-documented): 51 52 - **Advisory locks.** Session-scoped `pg_advisory_lock` errors out on CockroachDB; only transaction-scoped `pg_advisory_xact_lock` works. Worse, `pg_try_advisory_lock` and its unlock counterpart **silently no-op** rather than erroring. A design relying on advisory locks for mutual exclusion fails *silently* on CockroachDB — see §6, because this corrects a claim in this track's own hand-off to 04. 53 - **Savepoints.** `ROLLBACK TO SAVEPOINT` does not fully match PostgreSQL semantics for cursors defined before the savepoint. 54 - **`SELECT ... FOR UPDATE`.** Not usable with cursors on CockroachDB; less optimized under READ COMMITTED than SERIALIZABLE. 55 - **`ON CONFLICT DO UPDATE` under contention.** Falls inside the §1 retry framework — a contended upsert under CockroachDB SERIALIZABLE can itself throw `40001`. This one matters disproportionately here: `Update()` in our contract *is* a portable upsert, so our single most-used write path is the one exposed to retryable errors. 56 - **Transaction priorities.** `SET TRANSACTION PRIORITY` is CockroachDB-only with no PostgreSQL equivalent — a shared interface must not expose it uniformly. 57 58 ## 6. Correction to this track's own `migration-approach.md` 59 60 `./migration-approach.md` R2 stated that "CockroachDB does not implement advisory locks." That is too strong and is corrected: transaction-scoped advisory locks (`pg_advisory_xact_lock`) do work; session-scoped ones error; and the `pg_try_*` variants silently no-op. 61 62 **The conclusion R2 draws is unchanged and is in fact strengthened.** A migration-runner lock built on `pg_try_advisory_lock` would not fail loudly on CockroachDB — it would return as though the lock were acquired and let two runners proceed. Single-runner mutual exclusion must come from 04's orchestration layer, not from the database. The requirement stands; only my reason for it was imprecise, and the corrected reason is worse than the one I gave. 63 64 ## Could not verify 65 66 Stated as gaps rather than filled with inference: 67 68 - Whether the `#42858` SQLSTATE inconsistency extends to ordinary INSERT-time FK *data* violations (`23503`) on current CockroachDB versions — the closed issue documents a DDL-time case only. 69 - The exact current-version message/`DETAIL` structure diff between the engines for `23505`/`23503` — specifically whether CockroachDB populates `Detail` and `ConstraintName` on `pgconn.PgError` identically to PostgreSQL. **This gap is the direct reason §3 mandates SQLSTATE-only matching**; the rule holds regardless of how the gap resolves, which is why it did not need resolving before writing the contract. 70 - Whether `crdbpgx.ExecuteTx` has behaviourally diverged from the `database/sql` variant `crdb.ExecuteTx`. 71 72 ## What this changes downstream 73 74 1. **S1 contract — error semantics.** Sentinel translation is by SQLSTATE only, never by message text. Stated as a requirement on 03, with this document as the citation. 75 2. **S1 contract — a new decision, not previously in scope.** Either every write goes through a `crdb.ExecuteTx`-style retry wrapper (near-zero cost on PostgreSQL, load-bearing on CockroachDB), or the CockroachDB backend is explicitly documented as running at READ COMMITTED to drop the requirement. One of the two must be chosen and written down; leaving it unstated is how the intermittent-failure mode gets shipped. **This is the decision this research pass was worth running for.** 76 3. **`./multi-db-strategy.md`.** The shared-package justification is amended from "wire protocol and SQL surface compatibility" to that plus the named seam. The two-package structural split is unaffected. 77 4. **`./migration-approach.md` R2.** Corrected per §6; conclusion unchanged. 78 79 *AI tooling note: scoped research question authorized and framed by the 05 lead (Claude Opus 5) after a hire's flag, executed by one Claude Sonnet 5 research subagent with web search, synthesized and fact-checked against the cited primary sources by the lead. The hire who raised the question did not run the research — per `../CASTING.md` §3, deep research is leads-only.* 80
{
"command": "cd /Users/warrenthompson/Source/LoginID\necho \"=== footer tails (01 + 05 handoff-legibility) ===\"; for f in 01-product-industry-research-design/decisions/*.md 05-data-ops/decisions/handoff-legibility-05-03.md; do echo \"--- $f\"; tail -n 4 \"$f\"; done\necho; echo \"=== version labels ===\"; grep -rn 'handoff-04-secrets.md` v[0-9]\\|handoff-04-secrets.md v[0-9]\\|Status: \\*\\*v[0-9]' --include='*.md' . | grep -v '^./.git'\necho; echo \"=== root/track CLAUDE.md Status lines ===\"; grep -n \"^## Status\" -A2 CLAUDE.md 03-engineering-delivery/CLAUDE.md 04-infra-devops/CLAUDE.md | grep -v \"^--$\"\necho; echo \"=== cursor/offset ===\"; grep -rn -i \"cursor-based\\|keyset\\|offset-based\\|Offset *int\\|offset ceiling\" --include='*.md' 0[1-5]-*/ PLANNING.md | grep -v research-\necho; echo \"=== phone partial/prefix ===\"; grep -rn -i \"phone.*partial\\|partial.*phone\\|phone.*prefix\\|EXACT match on the E.164\" --include='*.md' 01-*/ 05-*/ 02-*/ | grep -v research-\necho; echo \"=== expand / mask in 02 & 03 ===\"; grep -rn -i \"expand\\|mask\" --include='*.md' 02-*/ 03-*/ PLANNING.md | grep -v research- | head\necho; echo \"=== caller_referral / caller-role / reason_code storage in 05 ===\"; grep -rn -i \"caller_referral\\|caller-role\\|reason_code\" --include='*.md' 05-*/ | head\necho; echo \"=== deletion_log / sweep / DeleteExpired in 03 & contract ===\"; grep -rn -i \"deletion_log\\|sweep\\|DeleteExpired\\|DeletionLog\" --include='*.md' 03-*/ 05-data-ops/multi-db-strategy.md PLANNING.md | head\necho; echo \"=== conformance in 03/04 ===\"; grep -rn -i \"conformance\" --include='*.md' 03-*/ 04-*/ PLANNING.md | head\necho; echo \"=== vendor_token_cache / idp_identity_cache ===\"; grep -rn \"vendor_token_cache\\|idp_identity_cache\" --include='*.md' . | grep -v '^./.git'\necho; echo \"=== idp_cache writers (Upsert/hydrat in 02,03) ===\"; grep -rn -i \"hydrat\\|Upsert\\|idp_cache\" --include='*.md' 02-*/ 03-*/ PLANNING.md | grep -v research- | head\necho; echo \"=== E.164 normalization ===\"; grep -n -i \"E.164\\|normaliz\" 05-data-ops/multi-db-strategy.md | head -20\necho; echo \"=== 'token-persistence' / 'no-plaintext-persist' / 'storage' stale cache refs in 03 ===\"; grep -rn -i \"token-persistence\\|no-\\s*plaintext-persist\\|never-persist-in-plaintext\\|TTL, storage\" --include='*.md' 03-*/ PLANNING.md\necho; echo \"=== Callum in 03 ===\"; grep -rn \"Callum\" 03-engineering-delivery/ | head\necho; echo \"=== connector aud config ===\"; grep -rn \"idp-connector-service\\|connector:identity-lookup\" --include='*.md' . | grep -v '^./.git'\necho; echo \"=== secret TEXT/BYTEA ===\"; grep -n \"nullable TEXT\\|BYTEA\\|PHC\" 02-ai-security-architecture/threat-model.md 05-data-ops/multi-db-strategy.md\necho; echo \"=== 'wait' artifact ===\"; grep -rn \" — wait, \" --include='*.md' . | grep -v '^./.git'\necho; echo \"=== PLANNING status vocab compliance ===\"; grep -n \"plan approved\" PLANNING.md | cut -c1-80",
"description": "Footer tails, version labels, and cross-repo citation greps for the report"
}<persisted-output> Output too large (32.2KB). Full output saved to: /Users/warrenthompson/.claude/projects/-Users-warrenthompson-Source-LoginID/47cb5423-3173-4f73-9e8a-3e2842e70675/tool-results/bprzddpi6.txt Preview (first 2KB): === footer tails (01 + 05 handoff-legibility) === --- 01-product-industry-research-design/decisions/claims-audit.md ## Outcome Two unsourced quantitative-flavored claims and two evaluator-intent claims softened in place across `industry-framing.md` and `personas-use-cases.md`; one claim (ROPC status) strengthened to match its actual source. All other claims in the three docs were already properly sourced or correctly hedged as vendor marketing — no further changes needed. Nothing required deep research; S2 (the structured written debate on the password-baseline framing) can now proceed arguing over claims that have been checked. --- 01-product-industry-research-design/decisions/password-baseline-debate.md But Imogen's underlying point survives, relocated: naming the gap, building it correctly, and marking `user_credential.method` and the connector interface as real extension points is a genuinely better submission than either pretending the gap is invisible or over-engineering a WebAuthn flow nobody asked for. That claim doesn't require reading LoginID's intent — it's true of a password-credential baseline built by *any* competent CIAM engineer, for *any* client, which is Tobias's own point. **Ruling:** `industry-framing.md` §2 and §4 are revised (this synthesis, applied directly) to drop every claim that the assignment's password baseline is LoginID's own deliberate narrative choice, and to re-ground the same engineering conclusions — hash/salt properly, `method` as a real discriminator, connector as a provider-agnostic interface — as industry-standard good practice that happens to point toward the same "after" LoginID sells, stated as forward-looking architecture rather than as an inference about the assignment author. Both positions are preserved above in full; the synthesis is binding for the document text, not a claim that Imogen's position was wrong to make — the debate is exactly why the framing is now stronger. ... </persisted-output>
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/decisions/handoff-legibility-05-03.md"
}1 # Decision: cold-read legibility pass on the 05→03 contract
2
3 Pattern: **newcomer's question** (`../../CASTING.md` §5). Decision owner: Priya Nandakumar, 05-data-ops. Story: S6 in `../PLAN.md`. Subject: the Go-shaped contract in `../multi-db-strategy.md`.
4
5 ## Why this was run, and the deliberate restriction
6
7 `../../02-ai-security-architecture/planning-approach.md` §2 records a standard this track asked the project to adopt: **a hand-off is implementable only if the receiving track can act on it without re-deriving the sender's reasoning.** Having asked for that standard, failing to test my own output against it would have been the worst available outcome.
8
9 The Newcomer archetype was at its org-wide cap, so the seat was played by Saoirse Byrne (Storyteller, this track's required temperament opposite). To make her a usable instrument she was **instructed at spawn not to read `multi-db-strategy.md`, `pii-governance.md`, or `research-data-ops-best-practices.md`**, and she sat out the S2 and S3 debates the other three hires participated in. That cost this track a seat in two debates. It was worth it.
10
11 She declared one contamination herself, unprompted: `../PLAN.md` told her what the contract *intended* to contain. She reported against the text rather than the intent and flagged the gap where the two diverged — which is how blocking item 4 below was found.
12
13 A second, independent cold read of this track's other hand-off (`../migration-approach.md`) was run in parallel by Wesley Okonkwo, on loan from 04. His findings are recorded at the end.
14
15 ## Outcome
16
17 **14 blocking items. All 14 accepted. All 14 fixed.** Plus three findings in the tail, all three adopted.
18
19 No item was declined, which is a fact about the quality of the read rather than about the document: six of the fourteen identified something the contract *asserted* but did not *enforce* or *define* — the same failure class the S2 ruling had already named, recurring in the very document that adopted the rule against it.
20
21 ### The four that would have become defects in 03's code
22
23 1. **`Update`: full replace or partial patch — undefined.** Nothing in the contract answered whether passing a profile with `Phone` nil NULLs the stored column or leaves it. Both readings are defensible; the wrong guess silently destroys data. *Ruled: full replacement, stated in bold. Patch semantics are read-modify-write at the caller.* She called this the single most consequential gap and was right.
24
25 2. **The error-translation rule was self-contradicting and half-unimplementable.** The contract said "translation is by SQLSTATE code only — never by constraint names," then immediately asked for different sentinels for `23505` depending on which column collided, which the code alone cannot say. Worse: **SQLite has no SQLSTATE at all**, so the rule was unimplementable across half the codebase. *Ruled: match structured codes per engine (SQLSTATE on PG/CRDB, extended result codes on SQLite), disambiguated by constraint names **we define in our own DDL** — our artifact, stable across engines, unlike an engine-generated message. Full mapping table added; every constraint in §5 now explicitly named.*
26
27 3. **`Upsert` could not serve the caller it was justified with.** The contract introduced `Upsert` for the IDP hydration path, which "does not know whether the row exists" — but `Upsert` conflicts on `id`, and a caller that doesn't know the row exists doesn't know its `id` either. There is no unique key on phone or name. *Ruled: the fix is deletion of the claim, not explanation. `Upsert` is for callers that already hold an `id`; resolving an `/identity` payload to an existing profile is explicitly the connector's problem.*
28
29 4. **No sentinel for write-side validation.** `ErrInvalidQuery` protected reads; nothing protected writes, so the commonest caller mistake — violating a CHECK constraint on `source`, `name`, `phone` or `country` — reached the service layer as a raw, engine-specific driver error. *Ruled: `ErrInvalidArgument` added, with `23514` / `SQLITE_CONSTRAINT_CHECK` mapped to it.*
30
31 ### The item that applied this document's own standard back at it
32
33 5. **`ErrInvalidMethod` is documented as "unknown *or inactive*", and the FK catches only *unknown*.** `is_active = false` had no enforcement site, in a numbered list whose entire premise is that every guarantee names one. *Ruled: added as §6 item 10, enforcement site named as DAO-layer validation plus a conformance test, and flagged as one of the two weakest sites in the contract rather than quietly patched.* The standard catching its own author is the strongest evidence available that it works.
34
35 ### The remaining nine, all accepted
36
37 `Create` and the `ID` field contradicted each other (§2 said the DAO generates it, §3 returned `ErrAlreadyExists` "if the ID is taken") — ruled: caller passes `""`, non-empty is `ErrInvalidArgument`. Ownership of `CreatedAt`/`UpdatedAt` unstated — ruled: DAO-owned, ignored on input. `total` never defined in the contract despite `PLAN.md` naming it as something S2 had to settle — the intent-versus-text gap; now defined as all matching rows ignoring limit/offset, and as a magnitude rather than a promise of reachability. `Phone` match semantics contradicted between the struct comment ("exact") and the index table ("exact/prefix") — ruled exact. All-nil `ProfileQuery` legality unstated — ruled legal. `Delete` on a missing row unstated — ruled `ErrNotFound`. `GetByUsername` case-folding ownership unstated — ruled DAO-side. The SQLite `GLOB` phone constraint was left as "a GLOB equivalent" — she correctly noted that inventing it is a design decision, not an implementation detail; it is now given in full.
38
39 ### Surprise-versus-warning audit
40
41 She was asked, for each of the contract's three counter-intuitive behaviours, whether a reader meets the surprise before or after the sentence warning them:
42
43 | Behaviour | Verdict | Action |
44 |---|---|---|
45 | `Update` does not create | **Warned first**, immediately under the method block | none |
46 | Cascade delete profile→credential | **Surprise ~140 lines before its explanation** at §6.2 | pointer added at the method list |
47 | SQLite `Search` parity | **No warning at all** — the only trace was "Postgres + CockroachDB only" in the index table, which a reader cannot turn into behaviour | new subsection: same results, same order, no index, full scan; non-ASCII folding the named residual |
48
49 ### Tail findings, all adopted
50
51 - **The retry seam ruled *that* writes retry, never *where* the wrapper sits.** Three implementers would produce three faithful, different codebases. Now settled: an unexported helper inside the `postgres` package; **no shim at all** in `sqlite` (a no-op would imply a shared abstraction that does not exist); not in the composite, which would wrap reads and push engine-specific behaviour above the interface boundary.
52 - **The `"postgres"` / `"cockroachdb"` driver-string split asserted the seam "needs to know which engine" without saying how it finds out**, given both select the same package. Now: an unexported `engine` field on the implementation struct, which is the entire reason the two strings are distinct.
53 - **The address rows collapsed four columns into one `CHECK (x ...)` plus three "as above"** — requiring the reader to substitute the column name four times, immediately above the row where the SQLite variant was genuinely missing. Her point: the reader is trained to fill in a blank just before reaching a blank they cannot fill. All four written out and named.
54 - **Organized by artifact; implemented by verb.** Four of the fourteen blockers existed only because no single place assembled a method's full story. She raised this as a question rather than a rewrite request and said explicitly that "no" was a fine answer. *Adopted:* §3a, a per-method table — who supplies the ID, who stamps timestamps, replace-or-patch, errors returned — which closes those four by construction rather than one prose sentence at a time.
55 - **On "enforcement site" as vocabulary**, she volunteered the opposite of a finding: it arrives self-defining and should *not* get a glossary entry. She also noted that §6's admission of its own weakest link reads as honesty rather than hedging only because the standard was stated first — the ordering is load-bearing.
56
57 ### Jargon undefined on first use
58
59 `gin_trgm_ops`/GIN, `crdb.ExecuteTx`, SQLSTATE, `COLLATE "C"`, `40001`/`23505`/`23503`, SERIALIZABLE vs. READ COMMITTED — all now defined in a vocabulary block. Bare `S1`/`S2`/`S3` story shorthand used without expansion: **the second reader in a row to catch this**, after Wesley found it in `migration-approach.md`, which makes it a habit of the author's rather than an oversight. Fixed as a class in both documents.
60
61 ## Parallel cold read: `migration-approach.md` (Wesley Okonkwo, 04, on loan)
62
63 Six of seven findings were undefined-on-first-use vocabulary: `goose`, `goose fix`, SQLite "type affinity", "advisory lock", and `pg_trgm`. All now defined inline.
64
65 One was not cosmetic. He could not tell whether `pg_trgm` was background or actionable — and it is actionable: it is precisely why the migration runner needs `CREATE EXTENSION` privileges on PostgreSQL and none on CockroachDB. Theo confirmed the consequence downstream: he had not broken the migration-runner credential out as its own row in `secrets-delivery.md`, and has now separated it from the runtime DML-only credential. **A glossary gap in this track's document had propagated into a missing row in another track's secrets inventory.**
66
67 He also flagged, against his own stated blind spot (mistaking a deliberate open item for a gap), that R2's "tell me if the platform cannot guarantee single-runner exclusion" read as a genuinely open question handed to 04. It was, and it now says so in bold.
68
69 ## What this pass establishes
70
71 The hand-off standard this track asked the project to adopt is testable, and the test fails on a document written by the person who proposed the standard. That is the useful result. A contract reviewed only by people who watched it being written is reviewed for correctness, not for legibility, and those are different properties — every one of the fourteen items was invisible to four readers who already knew what the document meant.
72
73 **Recommendation for any future run of this seat: keep the reader cold, and accept the cost.** Saoirse's own closing assessment is the argument: she cannot say which of the fourteen she would still have found had she read the debates first, and that uncertainty is exactly why the restriction is worth its price.
74
75 ---
76
77 *Pattern footer (`../../CASTING.md` §5 ⟨02⟩). Seats: cold reader Saoirse Byrne — sonnet, 2 turns (read, tail); parallel cold reader Wesley Okonkwo (04, on loan, ≤4 turns authorized) — sonnet, 1 turn, relayed by the 04 lead; revisions and rulings Priya Nandakumar — Claude Opus 5. Hire turns consumed: 3. Blocking items raised: 14. Accepted: 14. Declined: 0. Tail findings: 3, all adopted.*
78 {
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/decisions/rationale-cold-read.md"
}1 # S3 — Newcomer's-question cold read of `api-connector-design-rationale.md`
2
3 **Reader:** Wesley Okonkwo (Newcomer, track 04), on loan under the bounded two-party arrangement approved by team-lead (2–4 turns total across S3/S6). Relayed via the 04 lead session (`04-infra-devops-47`) since Wesley's environment has no cross-session messaging. **Fixed by:** Naomi Voss. **Pattern:** newcomer's question, per `PLAN.md` S3 (primary target — this is the file 03 has to *act* on, per the hand-off standard in `planning-approach.md` §2).
4
5 ## Method
6
7 Wesley read `api-connector-design-rationale.md` cold — as 03 would, no other project context — right after the "Contract summary for 03" section was added, and reported anything he could not act on without asking a question first.
8
9 ## Findings (turn 1 of the loan)
10
11 - **Undefined terms on first use:** "discriminator" (as a column concept), "blast radius", "resource-owner-password-credentials grant" / "OAuth2 grant family" (assumed named-grant-type knowledge), "mTLS", and the OIDC Core `address` claim referenced without inlining its shape.
12 - **Unresolvable cross-references:** "Persona 2" / "Persona 3" named only by label, not defined in this file; "S2" used as unglossed shorthand for a decision record.
13 - **One real ambiguity, not just a missing gloss:** the `expand` parameter was called a "toggle" in §2 and a "flag" in the Contract Summary, with neither saying whether it's top-level in the `POST /profiles/search` body or nested — he said he'd have had to ask before writing a Go struct around it.
14 - Everything else in the Contract Summary (field names, cardinality, out-of-scope list) read as directly actionable.
15
16 ## Fixes applied
17
18 - **`expand` shape resolved**, the one true ambiguity: it's a top-level `expand: bool` field in the search request body, sibling to the query fields — e.g. `{"name": "...", "phone": "...", "expand": false}`. Stated identically in both places it's mentioned (§2 and the Contract Summary), with the exact JSON shape given once and cross-referenced the second time so the two mentions can't drift again.
19 - **Persona 2 / Persona 3** now get a one-clause inline gloss at first use in each section ("the internal user who looks up a customer record...", "the onboarding flow that pre-fills a new user's profile at signup...") pointing to `personas-use-cases.md` for the full definition, rather than assuming the reader has read that file first.
20 - **ROPC** spelled out and given a one-clause definition ("one of several standard ways an OAuth2 client obtains a token, this one taking a raw username/password directly") rather than assumed.
21 - **"S2"** given a one-clause gloss ("the second story in `PLAN.md`'s task list, not a spec term") at its point of use in the out-of-scope list, since that's the reference most likely to confuse a reader who hasn't seen this track's plan.
22 - Left "discriminator," "blast radius," and "mTLS" as-is: each already carries an inline explanation or a named alternative at first use (mTLS appears in a list of three named auth mechanisms, not standalone), and 03 is an engineering track — over-glossing standard terms for an implementer reader is the opposite failure mode from the one this pattern exists to catch.
23
24 ## Outcome
25
26 The one blocking ambiguity (the `expand` field's shape) is closed with a concrete JSON example; the cross-reference gaps that would have cost 03 a round-trip question are closed inline. No further changes to the Contract Summary's substance — field names, cardinality, and the out-of-scope list were already directly actionable per Wesley's read. Turn 1 of 2–4 used.
27
28 ## S6 — secondary cold read of `industry-framing.md`
29
30 **Purpose:** evaluator-legibility test, not an implementer's read — can a reader with no other project context state the thesis and the post-S2 "before/after, not author-intent" distinction in their own words after one pass?
31
32 **Reader's restatement (turn 2 of the loan, relayed via `04-infra-devops-47`, Wesley's environment has no cross-session tool):** the submission builds a correctly-done, industry-standard password/legacy-connector baseline — not a mistake to silently "fix" with FIDO2 — but deliberately leaves visible extension seams (`user_credential.method`, the connector's provider interface) toward what LoginID actually sells (passkeys, OIDC federation, eventually agent-identity delegation), without a rewrite. Naming that gap is itself the signal of market fluency for evaluators. He further correctly identified that the document's current version walked back an earlier claim that LoginID deliberately chose this gap (dropped as unfalsifiable per the S2 debate), and now frames password + static connector as the conventional "before" any CIAM backend starts from, with FIDO2/OIDC/agent-identity as the "after" it evolves toward — regardless of what the assignment author meant. His one caveat: he inferred the S2 resolution from this document's own summary of it, not from reading `decisions/password-baseline-debate.md` directly.
33
34 **Outcome:** thesis and the before/after-vs-author-intent distinction both restated correctly and completely — no fix needed. His caveat is itself evidence the doc's summary of S2 is legible enough to stand in for the full debate record for a reader who only needs the thesis, which is exactly what `industry-framing.md` is for (evaluators read this file; they don't need to open `decisions/`). Loan closed at 2 of 2–4 turns used — no further turns needed for this track.
35 {
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/decisions/password-baseline-debate.md"
}1 # S2 — Structured written debate: is the password baseline a deliberate before/after simplification, or a contradiction the framing over-reads?
2
3 **Position:** Imogen Hale (Storyteller). **Rebuttal:** Tobias Lindqvist (Designated Skeptic). **Fact-check:** Desmond Okafor (Spreadsheet). **Synthesis:** Naomi Voss. **Pattern:** structured written debate, per `PLAN.md` S2. Both positions preserved below in full — LoginID is grading the reasoning, and a preserved dissent is better evidence of reasoning than a clean conclusion.
4
5 ## Position (Imogen Hale)
6
7 Here's the story a support analyst would need, and it's the whole argument: LoginID handed a candidate a task shaped exactly like their own product, minus the one thing their product is famous for. That's not a coincidence you stumble into by accident — an assignment doesn't specify `user_credential(username, method, password)` *and* a `/auth` endpoint that takes `{"username", "password"}` by drifting there. Both choices point the same direction, independently, in a company whose entire brand is "we removed the shared secret." When two separate parts of a spec agree on the same omission, the honest read is design, not oversight. That's the whole basis for §2's claim, and the audit left it standing — softened from "almost certainly intentional" to "we read this as intentional, given the gap's precision," which is exactly the confidence level the evidence supports. Not proof of intent. A read, defensibly stated.
8
9 Now, whose day does this framing actually change? Not the evaluator's — they already know what they built. It changes *mine*, the candidate's, because it tells me what kind of submission to write. If I treat the password field as an embarrassing accident to bury, I either build an insecure relic and hope nobody notices, or I "fix" it by silently substituting a WebAuthn flow nobody asked for — which answers a different question than the one on the page. Both are worse than naming the gap. The before/after frame is the one move that lets me build exactly what was specified, with full rigor, *and* show I understand why a passkey company would ask for it that way. That's not spin over substance — it's the only reading that lets the submission do both jobs (answer the question, demonstrate market fluency) without contradicting itself.
10
11 Where does this stop being a story and start being a stretch? Right where the audit already drew the line: the claim about what the exercise "is testing for" was downgraded from asserted fact to a stated belief, and it should stay there. I'm not asking anyone to believe I've read LoginID's grading rubric. I'm asking them to believe that a candidate who names the gap, explains why it's there, and marks the seam where it closes (`user_credential.method` as extension point, the connector's pluggable-provider shape) has made a stronger and more honest submission than one who either pretends the gap is invisible or apologizes for it. That's a claim about what makes a *good submission*, not a claim about the evaluator's internal state — and it survives the audit's softening exactly because it was rewritten to be that claim.
12
13 The "contradiction" reading treats the password baseline as something the framing has to explain away. I don't think it needs explaining away — it needs *naming*, once, plainly, and then building around correctly. A contradiction is a fact that damages the thing sitting next to it. This doesn't damage anything: the assignment says build a DAO with a password field, and I build a DAO with a password field, hashed and salted, with the auth mechanics fully specified by 02 and the schema by 05. Nothing in the submission claims passwords are secure or that LoginID endorses them. The only claim is narrative — that the gap is legible and load-bearing for how the reader should interpret everything downstream of it, the same way "we know this is the before picture" reframes a plain CRUD layer into an intentional baseline.
14
15 Last thing, because Desmond will ask for it before Tobias even opens his mouth: is there a number here? No — and there doesn't need to be one. This isn't a market-sizing claim or a drop-off percentage; it's an inference about design intent, drawn from two independent textual signals in the same spec, stated at "we read this as" confidence, not "we know this." That's exactly the kind of claim the audit was built to catch if it overreached, and it already trimmed the two sentences that overreached on evaluator intent. What's left is the load-bearing version, and it's the one worth keeping.
16
17 ## Rebuttal (Tobias Lindqvist)
18
19 Imogen's case rests on convergent design: two independent spec choices (the `password` field, the `/auth` body shape) point the same direction, so it must be intentional. But convergence toward the *industry-standard baseline* isn't evidence of authorial intent — it's evidence of picking a familiar assignment template. Every take-home DAO/API exercise in this space specifies username+password, because it's the shape every candidate and every evaluator already knows how to grade quickly. The "two signals agree" argument only works if the alternative — a passwordless baseline — was ever plausible for a generic take-home. It wasn't. You can't infer intent from the absence of a road not taken.
20
21 That's the real seam Imogen's version still steps over, even at the softened confidence level. "We read this as intentional, given the gap's precision" is still a claim about what LoginID meant by omission — it just wears a hedge. A hedge on an unfalsifiable claim is still an unfalsifiable claim; it just apologizes for itself. What would have to be true for the "deliberate before/after" reading to be wrong? Nothing observable — that's the tell. There's no artifact in the assignment text, no comment, no alternate spec, that distinguishes "we designed this gap on purpose" from "we wrote a normal assignment and didn't think about the gap at all." A reading that can't be falsified by anything in front of us isn't a design decision, it's a narrative convenience — and narrative convenience is exactly what a passkey company's outside consultant should be most suspicious of when it flatters the client.
22
23 Where I agree: the *engineering* response is identical either way. Build the password DAO correctly, hash and salt it, mark `method` as an extension point, don't silently swap in WebAuthn. Imogen is right that pretending the gap is invisible is worse than naming it. My objection isn't to naming the gap — it's to dressing the naming up as an inference about LoginID's design intent, when it's actually just good practice for *any* password-handling exercise regardless of who assigned it. You don't need "LoginID meant this as a before-picture" to justify hashing passwords properly or noting that a passkey vendor would ultimately migrate `method` toward WebAuthn. Those are sound choices on their own terms, with no theory of the client's mind attached.
24
25 The contradiction is smaller and more honest than Imogen's "no contradiction" claim allows: it's not that the submission damages itself by including a password field — of course the assignment requires one. It's that a submission which repeatedly signals "we know this is *your* legacy pattern, we're narrating it as your before-picture" risks reading as flattery dressed as market fluency, when the more defensible and equally sophisticated move is to treat it as the industry-standard baseline any credential store needs, and reserve the passkey commentary for where it's actually load-bearing — the `method` discriminator and the connector's provider-agnostic shape, both of which are real extension points already in the design, not narrative.
26
27 **Counter-proposal:** Rewrite `industry-framing.md` §2 to drop the "this gap is LoginID's own passkey story" framing entirely. State plainly: this is the conventional password-credential baseline used across the industry; it's good practice to build it correctly (hashed, salted, `method` as a real discriminator) regardless of who's asking; and separately, in §4, note — without claiming intent — that a company built on passkeys would naturally evolve this baseline toward WebAuthn, which is why `method` and the connector interface are designed as extension points now. Same engineering, no unfalsifiable claim about the evaluator's mind, and the passkey fluency shows up as forward-looking architecture rather than retrospective mind-reading.
28
29 ## Fact-check (Desmond Okafor)
30
31 I don't have Imogen's Position text itself in this thread (only referenced as "same text sent to Tobias"), so I can only check it against what S1 already covered. The one factual anchor both sides share — "this is almost certainly intentional, not an oversight" — was already flagged in my S1 audit as **asserted**, not verified: no artifact in the project files establishes LoginID's intent, only the observed gap between the assignment spec and LoginID's product (which *is* verified). If Position repeats that line unsoftened, it's reintroducing a claim S1 already flagged.
32
33 Tobias's rebuttal is factually clean and introduces no new sourcing problem — his claims are about argument structure (falsifiability, base-rate of take-home templates), not facts requiring a citation, and don't contradict anything in the research docs. His one empirical-sounding claim ("every take-home DAO/API exercise in this space specifies username+password") is itself unsourced/asserted — flag it symmetrically if it lands in the written record, same standard I'd apply to Position.
34
35 Net: neither side has a sourcing edge. The dispute is genuinely about inference, not evidence.
36
37 ## Synthesis (Naomi Voss)
38
39 Tobias wins the narrow, falsifiability point cleanly, and the fact-check confirms it: "LoginID designed this gap on purpose" is not distinguishable from "this is an ordinary take-home template" by anything in the assignment text. Desmond's audit already forced that claim down to "we read this as" confidence in S1; Tobias's rebuttal shows that even at that confidence level, it's still a claim about the evaluator's mind, and hedging it doesn't make it falsifiable. It stays out.
40
41 But Imogen's underlying point survives, relocated: naming the gap, building it correctly, and marking `user_credential.method` and the connector interface as real extension points is a genuinely better submission than either pretending the gap is invisible or over-engineering a WebAuthn flow nobody asked for. That claim doesn't require reading LoginID's intent — it's true of a password-credential baseline built by *any* competent CIAM engineer, for *any* client, which is Tobias's own point.
42
43 **Ruling:** `industry-framing.md` §2 and §4 are revised (this synthesis, applied directly) to drop every claim that the assignment's password baseline is LoginID's own deliberate narrative choice, and to re-ground the same engineering conclusions — hash/salt properly, `method` as a real discriminator, connector as a provider-agnostic interface — as industry-standard good practice that happens to point toward the same "after" LoginID sells, stated as forward-looking architecture rather than as an inference about the assignment author. Both positions are preserved above in full; the synthesis is binding for the document text, not a claim that Imogen's position was wrong to make — the debate is exactly why the framing is now stronger.
44 {
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/decisions/claims-audit.md"
}1 # S1 — Claims audit 2 3 **Auditor:** Desmond Okafor (Spreadsheet, claims-auditor seat). **Synthesis/edits:** Naomi Voss. **Pattern:** claims audit (one-hire review, not a catalog pattern). **Inputs:** `industry-framing.md`, `personas-use-cases.md`, `api-connector-design-rationale.md`, checked against `research-loginid-company.md`, `research-oauth-history.md`, `research-oidc-address-claim.md`. 4 5 ## Method 6 7 Every quantitative or evaluative claim in the three framing/rationale docs gets a row: claim, location, evidence file, status (verified / asserted / marketing-sourced / softened), and — where the claim wasn't supportable as written — the softer wording it was changed to. No outside research was commissioned; PLAN.md §3 rules that out for narrative decoration, and none of the weak claims needed it — they needed softening, not sourcing. 8 9 ## Table 10 11 | Claim | Where | Evidence | Status | Resolution | 12 |---|---|---|---|---| 13 | LoginID passkeys have "no shared secret ever transmitted or stored server-side" | `industry-framing.md` §2 | `research-loginid-company.md` §1 (marketing) | Verified to source (source itself is vendor marketing) | Kept as-is | 14 | "This is almost certainly intentional, not an oversight in the assignment's wording" | `industry-framing.md` §2 | None — inference about LoginID's intent | Asserted | Softened → "We read this as intentional, given the gap's precision" | 15 | "...it's the move this exercise is actually testing for" | `industry-framing.md` §4 | None — claims to know evaluator intent | Asserted | Softened → "the move we believe this exercise is testing for" | 16 | Dick Hardt = OAuth 2.0 editor (RFC 6749), not OAuth 1.0 co-author | `industry-framing.md` §3 | `research-oauth-history.md` §2, RFC citations | Verified | Kept as-is | 17 | LoginID's 2025–26 agentic-commerce pivot | `industry-framing.md` §3 | `research-loginid-company.md` §1/§3 (marketing, already flagged company-stated) | Verified-as-marketing-claim (correctly hedged already) | Kept as-is | 18 | ROPC is "discouraged" by most providers | `api-connector-design-rationale.md` §3 | `research-oidc-address-claim.md` Q2 → RFC 9700 §2.4: "MUST NOT be used" | Verified but understated | Strengthened → "RFC 9700 now states MUST NOT be used" | 19 | Address fields match OIDC Core §5.1.1 | `api-connector-design-rationale.md` §3 | `research-oidc-address-claim.md` | Verified | Kept as-is | 20 | "retrieval-by-ID is the easy 80%" | `personas-use-cases.md`, Persona 2 | None — unsourced figure | Asserted / unsourced number | Softened → "retrieval-by-ID is the low-friction case" | 21 | "fewer form fields at signup measurably reduces drop-off" | `personas-use-cases.md`, Persona 3 | None — marketing-pattern claim, no project evidence | Marketing-sourced pattern | Softened → "is widely believed to reduce drop-off" | 22 | "'sign in with X' patterns dominate consumer onboarding" | `personas-use-cases.md`, Persona 3 | None | Asserted | Softened → "are common in consumer onboarding" | 23 24 ## Outcome 25 26 Two unsourced quantitative-flavored claims and two evaluator-intent claims softened in place across `industry-framing.md` and `personas-use-cases.md`; one claim (ROPC status) strengthened to match its actual source. All other claims in the three docs were already properly sourced or correctly hedged as vendor marketing — no further changes needed. Nothing required deep research; S2 (the structured written debate on the password-baseline framing) can now proceed arguing over claims that have been checked. 27
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/PLAN.md"
}1 # Track 05 — Data Ops: Planning Phase Plan 2 3 Lead: Priya Nandakumar (opus/high for this phase). Track directory: `05-data-ops/`. Org plan: `../PLAN.md`. Board row: `../PLANNING.md`. Rules, palette, patterns, hire procedure: `../CASTING.md`. Coordination approach: `../02-ai-security-architecture/planning-approach.md`. 4 5 ## Context 6 7 All three of the assignment's questions sit on top of one schema and one DAO contract, and this track owns both. That is the whole reason 05 is on the first-wave critical path: 03 cannot write the DAO until the contract is stable, and 04 cannot plan migration execution until the migration approach is named. 8 9 The first-wave design work already exists here as prose — `research-data-ops-best-practices.md` (sourced research), `multi-db-strategy.md` (interface contract and schema shape), `pii-governance.md` (retention and non-conflation rules). What does not exist is the thing 03 actually needs. `planning-approach.md` records my own commitment on this and Marcus adopted it project-wide: a hand-off is implementable only if the receiver can act on it **without re-deriving the sender's reasoning**. `multi-db-strategy.md` today describes `Search(ctx, query) (results []UserProfile, total int, error)` in a sentence. It does not say what `query` is, which fields are nullable, what `Update` does when the row is gone, or whether `total` is the filtered count or the page count. Renata would have to invent all of that, and inventing it is a design decision that belongs to this track, not hers. 10 11 So the deliverable this phase is aimed at is narrow and specific: turn the prose contract into a Go-shaped contract — interface signatures, field-level schema with nullability and types, error semantics, and explicit out-of-scope notes telling 03 what it must not redesign. Everything else in this plan either feeds that artifact or unblocks 04. 12 13 The planning-phase rule holds throughout: this is a Go-*shaped* contract written inside a markdown file. No `.go`, no `.sql`, no `go.mod`, until Warren's explicit go-ahead. A fenced Go block inside `multi-db-strategy.md` is the design; a file the compiler would accept is code. 14 15 ## 1. Deliverables and tasks 16 17 Six deliverables, each sized to one Jira Story under the track's Epic in project `LT`. Stories are listed in dependency order; S1 is the first Phase 3 deliverable and the one everything downstream waits on. Acceptance criterion for every story is the hand-off standard above. 18 19 ### S1 — Go-shaped contract in `multi-db-strategy.md` (the critical-path deliverable) 20 21 Extend `multi-db-strategy.md` in place — it stays the single source of truth, no new parallel doc. Adds, as fenced Go-shaped blocks and tables rather than prose: 22 23 - **Interface signatures** for `ProfileRepository`, `CredentialRepository`, `AuthMethodRepository` — exact method names, parameter and return types, and the `ProfileQuery` / `ProfileSearchResult` types `Search` takes and returns (shape ruled in S2). 24 - **Error semantics**, which today are one sentence about `ErrNotFound`. Needs the full sentinel set and when each is returned: `ErrNotFound`, `ErrDuplicateUsername` (unique-constraint violation on `user_credential.username`, which both backends must translate out of their driver-specific error into the shared sentinel), `ErrInvalidMethod` (FK violation on `method_id`), and the rule that everything else is wrapped with `%w` and passed through. Also: does `Update` on a missing row return `ErrNotFound` or upsert-create? (My position going in: `Update` is a portable upsert per the existing doc, so it creates — which means the doc must say so loudly, because "Update creates" is exactly the surprise that produces a bug in 03.) 25 - **Field-level schema table per entity** — column, Go type, SQL type per backend family, nullable yes/no, constraint, and a one-line note. Nullability is where this contract is most likely to be wrong and most expensive to fix, so it gets its own reviewer (S3). 26 - **Context and cancellation**: every method takes `ctx` first and is expected to honour cancellation; no method holds a transaction across calls. If the service layer needs a multi-statement transaction, that is a follow-up on the contract, not something 03 improvises — stated explicitly. 27 - **Explicitly out of scope for 03** — a numbered list so there is no ambiguity about what 03 must not redesign: the PII/credential separation (governance control, `pii-governance.md`), the UUID-everywhere PK strategy, the `auth_method` lookup table rather than a native enum, the `ON CONFLICT DO UPDATE` upsert form, the documented SQLite fuzzy-search parity gap, and the two-package (`postgres` serving Postgres + CockroachDB, `sqlite` separate) split. Each with a one-line "why, and who to flag if it doesn't fit." 28 - **Alignment note to 03's published factory**: `../PLANNING.md` service boundaries names `dao.New(driver, dsn string) (dao.Repository, error)` — a single `Repository` return type, while this track specifies three interfaces. Resolve it in the contract (my expected resolution: `dao.Repository` is a small composite that exposes `Profiles()`, `Credentials()`, `Methods()`, keeping Renata's factory signature stable exactly as she declared it) rather than leaving two tracks each thinking the other will bend. 29 30 Consumes: S2 (query shape), S3 (nullability review), and the S4 ruling. Produces the 05→03 hand-off. Story closes when S6's cold read comes back clean. 31 32 ### S2 — `Search()` query shape decision 33 34 The one genuinely open design question in the contract, and the one with the most ways to be quietly wrong. Decides: the `ProfileQuery` field set and which fields are optional; match semantics per field (name partial/fuzzy, phone exact-or-prefix on the E.164-normalized value, region/country exact); how empty and multiple filters combine (AND, and whether a wholly empty query is a legal "list everything" or an error); pagination as limit/offset versus a keyset cursor, with a maximum page size; sort order and whether it is stable enough for offset pagination to be meaningful at all; and what `total` means — total matching rows, or rows on this page. Also settles how the documented SQLite parity gap surfaces to a caller: identical results with worse performance, or ranked-vs-unranked differences a caller could observe. 35 36 Pattern: **adversarial pair** (`../CASTING.md` §5 names this decision by name). Artifact: `decisions/search-query-shape.md`, folded into S1. 37 38 ### S3 — Multi-DB abstraction ruling 39 40 `multi-db-strategy.md` already recommends two packages behind one interface and rejects dialect branching. That recommendation was reached by one agent reading sources. The catalog names this decision for **three hats**, and it is worth the three turns because it is the structural choice 03 builds the entire DAO around — codegen (sqlc) is a real third option that was never actually argued, only mentioned. Possible outcomes: the existing recommendation is confirmed (most likely, and the confirmation is then a documented ruling rather than an assumption), or codegen displaces hand-written SQL, which changes what S1's contract has to pin down. 41 42 Artifact: `decisions/multi-db-abstraction.md` with the three ≤300-word positions, my synthesis, and the pattern footer (model per seat, hire turns consumed) that 02 added to every pattern. 43 44 ### S4 — Migration approach hand-off for 04 45 46 `../PLANNING.md` lists 04 as blocked on "05's migration tool/approach". `multi-db-strategy.md` has a migration section, but in Theo's terms it is a preference, not a mechanism. Produces `migration-approach.md` — a new file, because its receiver is 04, not 03, and folding an infra hand-off into the DAO contract would make both harder to read: 47 48 - Tool: goose (already reasoned in `multi-db-strategy.md`; golang-migrate acceptable if 03 has tooling preferences — one rotating-devil's-advocate objection turn, not a debate). 49 - Directory layout and naming for the shared series plus the named per-backend override set (currently exactly one override: the `pg_trgm` GIN index, omitted on SQLite). 50 - What 04 must provide: when migrations run relative to service start, whether the service fails closed if the schema is behind, migration-lock behaviour under multiple replicas, and the DB privileges the migration runner needs versus the runtime user. 51 - Seed data: the `auth_method` `"password"` row is a seeded row, not a migration constant — how it is applied and what makes it idempotent. 52 - Explicitly not owned here: where the databases run, how the pipeline invokes goose, environment topology. Named so 04 does not read a boundary into this doc that isn't there. 53 54 ### S5 — Retention windows and the deletion-job contract 55 56 `pii-governance.md` states the policy shape and stops at "a product/legal decision this track doesn't own outright." That is true and it is also not actionable — 04 cannot build a retention sweep against it. This story puts numbers in a table, marked as **proposed defaults for a POC pending a product/legal ruling**, and specifies the mechanism the numbers plug into: what a sweep selects on (`source`, `updated_at`), whether deletion is hard delete or tombstone, what cascades to `user_credential` (the FK is already cascade-delete, so deleting a profile silently destroys its credentials — deliberate and worth stating), and what a subject-deletion request touches versus a bulk retention sweep. Separate proposed windows for `source = 'direct'` and `source = 'idp_cache'`, with the `idp_cache` window shorter and the reasoning stated. 57 58 Owner seat: the Spreadsheet hire, who will not accept "a reasonable window" as an answer. Artifact: `pii-governance.md` extended with a retention table; policy shape only, execution stays 04's. 59 60 ### S6 — Cold-read legibility pass on the 05→03 contract 61 62 The catalog calls the **newcomer's question** pass the highest-ROI pattern in the project and names "every 05→03 hand-off" as its use. A hire who has not watched this contract being written reads S1 cold and lists what they could not act on without asking me. I fix every item or record why not. This is the acceptance test for S1 and for the hand-off standard I asked the project to adopt; failing to run it on my own output would be the most embarrassing available outcome. 63 64 Artifact: `decisions/handoff-legibility-05-03.md`. Runs after S1 drafts, gates S1 closing. 65 66 ## 2. Hire roster — 4 hires 67 68 Matches the suggested cast in `../CASTING.md` §3 exactly. Variance check against the fourteen hires already registered: Systems Cartographer, Storyteller, Detail Hawk and Spreadsheet each currently sit at 1× org-wide, so all four land at 2× — at the cap, none over it, and none repeated within this team. Rule 2 (same archetype, different function) holds in every case, stated per hire below. Rule 3 (lead's temperament opposite) is Saoirse. Rule 5: this roster spans serious↔playful and cautious↔bold both. Model: **sonnet** for all four; none of these seats makes a foundational call alone — I hold those at opus, which is the entire reason this session was relaunched. 69 70 Two seats in my debate plan have no archetype left in the palette (Designated Skeptic and Newcomer are both at 2× org-wide). Rather than burn a hire on a near-duplicate, I substitute within this roster and say so explicitly in §3 — the pattern protocols are about the role played, not the label worn. 71 72 ### Hire 1 — Anders Vogel · Systems Cartographer · serious, bold 73 74 Function: multi-DB abstraction — drafts the interface contract's structural shape and the Proposer seat on `Search()`. Distinct from Ines Dabrowski's Systems Cartographer function in 03 (Go module layout and service topology): Anders draws the boundary *inside* the DAO between interface and per-backend implementation; Ines draws boundaries between packages and binaries. Different artifacts, no overlap. 75 76 > Persona paragraph for `../CASTING.md` §4 (127 words): 77 > 78 > Calm and unusually direct for someone who thinks in diagrams; opens by drawing the boundary before anyone argues about what sits on either side of it. Decides by mapping the interaction surface first — what crosses this line, in which direction, how often — then choosing the abstraction that makes the smallest surface. Pushes back hard on any interface whose implementations must know about each other, and on abstractions justified by a backend nobody has committed to adding. Six years on a payments platform's storage layer where a leaky repository interface bled dialect assumptions into three services, then four years at a data-infrastructure vendor building connectors across five engines. Blind spot: abstracts past the concrete — he will design for the fourth backend before the second one works. Concedes cleanly, in writing, and moves on. 79 80 ### Hire 2 — Saoirse Byrne · Storyteller · playful, bold (my temperament opposite, rule 3) 81 82 Function: consumer's-eye reader and cold-read seat — argues the contract from the position of whoever has to use it, and runs the newcomer's-question pass on S1. Distinct from Imogen Hale's Storyteller function in 01 (product framing and narrative thesis): Saoirse's subject is a schema and an interface, not a market; her output is "here is where this hand-off stops being followable," not a README thesis. 83 84 > Persona paragraph for `../CASTING.md` §4 (133 words): 85 > 86 > Warm, quick, and cheerfully unbothered by being the least formal person in a schema review; narrates a design as the journey of one record — created here, read there, deleted when. Decides by walking the path end to end and noticing where the story skips a step, which is usually where the bug is. Pushes back on documents that are correct but unfollowable, and on any governance rule stated so abstractly that nobody could tell whether they had broken it. Five years writing developer documentation and SDK guides at an API company, three as a solutions engineer who watched customers misread her own docs in real time. Reads SQL comfortably and writes Go badly enough to be an honest proxy for a confused implementer. Blind spot: charming beats correct — the clearer story is not always the true one. Disagrees by retelling it. 87 88 ### Hire 3 — Yusuf Karadag · Detail Hawk · serious, cautious 89 90 Function: DDL and nullability review, plus the numbered-objection seat on `Search()`. Distinct from Oren Castellan's Detail Hawk function in 03 (Go code review — error semantics, off-by-one): Yusuf reviews the schema and contract *before* code exists; Oren reviews code after. `../CASTING.md` §2 names this exact pair as the allowed second use. 91 92 > Persona paragraph for `../CASTING.md` §4 (129 words): 93 > 94 > Quiet, exacting, and slower to speak than everyone else in the room because he is still reading the column list. Decides one field at a time: type, nullability, constraint, default, and what happens on the day the constraint is violated in production. Pushes back on any nullable column whose null has no stated meaning, on "we'll validate it in the application layer," and on error handling that collapses three distinct database failures into one generic message. Nine years as a database engineer at a healthcare data processor under audit conditions, then three on a platform team migrating a large schema across engines. Deep in Postgres constraint semantics and the places SQLite quietly disagrees. Blind spot: cannot see the forest — he will hold a release over a defaulted column. Objects in numbered lists and expects numbered answers. 95 96 ### Hire 4 — Beatriz Achterberg · Spreadsheet · playful, cautious 97 98 Function: retention windows and the deletion-job contract (S5), plus the pragmatist hat in the three-hats run. Distinct from Desmond Okafor's Spreadsheet function in 01 (auditing evidence behind claims): Beatriz sets numbers that become policy rather than checking numbers someone else asserted. 99 100 > Persona paragraph for `../CASTING.md` §4 (131 words): 101 > 102 > Dry, genial, and faintly delighted when a question she was told was qualitative turns out to have a number hiding in it. Decides by building the smallest table that forces the choice into the open — one row per case, one column per thing that must be true — and refuses to move until every cell is filled or explicitly marked unknown. Pushes back on "reasonable," "appropriate," and "as needed" wherever those words stand in for a duration, and on retention policies with no named mechanism to enforce them. Seven years in data governance at an insurance group writing the retention schedules the auditors read, then three on a privacy-engineering team building deletion pipelines. Blind spot: she measures only what is measurable, and will quietly drop what resists a column. Disagrees by showing you the empty cell. 103 104 ## 3. Debate plan 105 106 Patterns are `../CASTING.md` §5 protocols; every run ends in a file under `05-data-ops/decisions/` with the ⟨02⟩ footer recording model per seat and hire turns consumed. 107 108 **Three hats — the multi-DB abstraction (S3).** Three parallel ≤300-word positions on per-driver implementations vs. dialect branches vs. codegen (sqlc): 109 - *Optimist* — Anders: the case for the cleanest structural answer, codegen included. 110 - *Pessimist* — Yusuf, substituting for the Veteran seat (both Veterans are 03's; Yusuf's failure-mode instinct fits): where each option breaks at the boundary, generated-code review burden, dialect drift. 111 - *Pragmatist* — Beatriz: what each costs in files, review surface and time for a take-home whose SQLite backend is explicitly not a production peer. 112 113 I synthesize and rule. 3 hire turns + my synthesis. Justified because it is the structural choice 03 builds against and codegen has never actually been argued here, only name-checked. 114 115 **Adversarial pair — the `Search()` query shape (S2).** Anders proposes the `ProfileQuery` / result shape; Yusuf writes numbered objections, each ending with the "what would change my mind" line ⟨02⟩ requires; Anders answers every objection in writing and may decline to revise, but never silently; I rule. Yusuf holds the Designated Skeptic seat, which is at 2× org-wide and unavailable — his numbered-objection habit is the part of that archetype this pattern actually needs. 3 hire turns + my ruling. 116 117 **Newcomer's question — the 05→03 contract (S6).** Saoirse reads the drafted S1 cold and lists what she could not act on without asking me; I fix or record why not. The Newcomer archetype is at 2× org-wide (Wesley Okonkwo, 04, currently on loan to 01), so Saoirse plays the seat — with one contingency: if her pass comes back thin, I will ask team-lead for a short Wesley loan for a second cold read, since the value of this pattern comes from genuine unfamiliarity and a hire who has read this track's docs is only a partial proxy. 1–2 hire turns. 118 119 **Rotating devil's advocate — two single-turn objections.** Beatriz objects to the migration approach (S4) before it goes to 04; Saoirse objects to the retention windows (S5) before they go into `pii-governance.md`. Cheapest pattern in the catalog; the seat rotates so neither becomes "the negative one." 120 121 **Explicitly not debated — and why:** 122 - *DAO CRUD semantics beyond `Search`* — `Get`/`Create`/`Delete` are settled by the assignment and by the existing doc; the catalog names DAO CRUD as red/blue waste and it is three-hats waste too. 123 - *No red team / blue team in this track at all.* The catalog reserves it for the connector token lifecycle, which is 02's. Attack-tree work against this schema would duplicate `../02-ai-security-architecture/threat-model.md` and cross an ownership line. 124 - *The address field names* (`street_address`, `locality`, `region`, `postal_code`, `country`) — fixe … truncated (20 KB cap)
{
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/PLAN.md"
}1 # Track 04 (Infra & DevOps) — Phase 1 Planning Plan
2
3 ## Context
4
5 This is the Phase 1 planning pass for track 04, per team-lead's brief and the org-wide `PLAN.md` / `PLANNING.md` / `CASTING.md` / `02-ai-security-architecture/planning-approach.md` scaffolding. No code or config artifacts yet — this file is a plan only. Track 04 is deliberately last in the dependency order: it designs how the engineering track's services get built, deployed, and operated, which only makes sense once those services have a shape. Nothing below gets executed until team-lead replies "hiring go" (for the roster) and Warren gives the separate implementation go-ahead (for any code/config).
6
7 ## Hiring status (post "hiring go")
8
9 Wesley Okonkwo, Bree Sandoval, and Callum Ferreira are live, standing named teammates, spawned directly by Theo in this session — see `CASTING.md` §6 step 5.
10
11 ## Deployment target note (added post-approval, Warren's direct input)
12
13 There is a real Kubernetes dev/lab cluster on Warren's LAN, operated day-to-day by an OpenClaw agent ("amber-kubernetes") running on a Mac mini reachable via SSH — this is not a hypothetical target, it's a specific, addressable cluster with an operator who already knows its conventions. Warren's explicit call: this stays a **design-only reference** for the submission. The deliverables below should name this cluster as the concrete deployment target (so the design reads as intentional rather than generic "some Kubernetes cluster somewhere"), including describing amber-kubernetes' role as the operational actor who'd apply manifests and know the cluster's existing namespace/ingress conventions — but nothing gets actually deployed, and amber-kubernetes is not contacted, during this take-home. If a real demo deployment is ever wanted, that is a separate, later decision Warren would make explicitly — not implied by this note.
14
15 ## 1. Deliverables & tasks (one Jira Story each; Epic = this track)
16
17 1. **Containerization design** — Dockerfile sketch/description for both the DAO-backed API service and the IDP connector service (multi-stage Go builds), described as targeting the real k8s dev cluster above (so it should read as a Deployment/Service manifest sketch, not just a bare Dockerfile). *Gated on:* 03's service boundaries (one binary or two, package layout) in `PLANNING.md`.
18 2. **CI pipeline sketch** — what runs on commit/PR: build, lint, test, security scan, and (as a documented final stage) a describe-only "apply to the lab cluster via amber-kubernetes" step — named, not executed. *Gated on:* nothing structural from other tracks, but the "test" stage assumes 03's test strategy exists in outline.
19 3. **Local dev-loop** — docker-compose sketch with a local Postgres profile and a SQLite-only profile (compose profiles, matching whatever env-var/config mechanism 03 picks for DB driver selection); this is the local counterpart to the cluster target, not a replacement for it. *Gated on:* 03's DAO driver-selection mechanism.
20 4. **Secrets delivery mechanics** — how DB credentials, IDP credentials, and signing keys reach the running services at deploy time (env injection, mounted files, or a secrets-manager sketch — mechanism only, not what's a secret or why), noting how that would map onto the lab cluster's existing secrets convention once known (not yet queried — see deployment target note). *Gated on:* 02's secrets inventory.
21 5. **Observability + never-log enforcement** — logging/metrics/tracing approach for both services, plus how the never-log list gets enforced in practice (structured logging with a redaction/allowlist discipline, not just a policy statement). *Gated on:* 02's never-log list.
22
23 Named hand-off inputs, all as files this track reads (not re-derives):
24 - `../PLANNING.md` → "Service boundaries" section, owned by Renata (03).
25 - 02's receiver-shaped secrets inventory + never-log list (per `planning-approach.md` §2, a deferred 02→04 hand-off — file path TBD, likely an addition to `../02-ai-security-architecture/connector-security.md` or a new short file).
26 - `../05-data-ops/multi-db-strategy.md` → migration tool/approach only (not migration content).
27
28 ## 2. Hire roster (3 hires, per Warren's 3–4/track sizing and Theo's small-surface argument for the low end of that range)
29
30 All three functions are genuinely useful here — not padding — because each does something Theo's own temperament won't: a Newcomer catches "assumed" jargon in hand-offs Theo would read past too fast; an Enthusiast is the required temperament opposite and the one voice arguing against Theo's own bias toward the minimum viable setup; a second Minimalist voice acts as an independent scope check on Theo's own designs (deadpan-minimalist reviewing deadpan-minimalist has no counterweight without a distinct persona doing it). No fourth hire — there's no fourth function this track's small deliverable set actually needs; adding one would be exactly the over-resourcing Theo argued against in `planning-approach.md`.
31
32 ### Hire 1 — Newcomer (function: hand-off legibility check)
33 - **Proposed name:** Wesley Okonkwo
34 - **Archetype:** Newcomer · temperament tags: `serious, cautious`-leaning but genuinely curious (bright/earnest, not deadpan)
35 - **Model:** sonnet
36 - **Patterns & role:** runs the Newcomer's-question pass (§5 of `CASTING.md`) on 03's service-boundaries hand-off and 02's secrets-inventory hand-off once both land — reads each cold, lists anything he couldn't act on without asking Theo to re-explain it, Theo fixes or annotates. This is the one pattern this track's plan authorizes.
37 - **Persona (draft, 137 words):** Bright, unguarded, asks the question everyone else stopped asking two meetings ago. Decides by restating what he thinks he just read and waiting to be corrected — if nobody corrects him, he assumes it's actually clear and says so in writing. Two years as a support engineer triaging on-call tickets for a mid-size SaaS company, one year as a junior platform engineer at a logistics startup, where he learned that a runbook nobody outside its author can follow is not a runbook. Comfortable with Docker, basic CI YAML, and reading a config file, but has never designed one from scratch. Pushes back on any hand-off that uses a term without defining it once. Blind spot: doesn't know what's already settled, so he sometimes flags a deliberate simplification as a gap. Disagreement: asks a follow-up question rather than asserting he's right.
38
39 ### Hire 2 — Enthusiast (function: Theo's required temperament opposite; argues for trying the promising infra option)
40 - **Proposed name:** Bree Sandoval
41 - **Archetype:** Enthusiast · temperament tags: `playful, bold`
42 - **Model:** sonnet
43 - **Patterns & role:** informal counterweight seat (not a named catalog pattern — the catalog itself says this track is too small for red/blue or three-hats) inside the containerization, CI, and dev-loop design tasks: for each, she writes the "what's the more capable/interesting option and what would it cost us" case (e.g., a managed secrets manager over a mounted file, a fuller CI matrix over the minimum) before Theo rules on it. Documented as a short "considered and declined/adopted" note per deliverable, not a separate debate artifact — proportionate to a three-task track.
44 - **Persona (draft, 128 words):** Bold, quick to say "let's just try it," genuinely energized by the newer option in the room. Decides fast and revises fast — she'd rather propose three things and cut two than deliberate over one. Three years building CI/CD for a Series B fintech's platform team, one year as a DevRel engineer demoing infra tools, which is where the optimism about new tooling comes from and also where she watched three demos fail under real traffic. Deep hands-on with GitHub Actions, container registries, and secrets-manager integrations. Pushes back whenever a design defaults to "the minimum" without pricing what's given up. Blind spot: discounts the operational cost of the thing she's excited about. Disagreement: stays cheerful, concedes fast when shown the failure mode, rarely re-litigates after that.
45
46 ### Hire 3 — Minimalist (function: independent scope guard)
47 - **Proposed name:** Callum Ferreira
48 - **Archetype:** Minimalist · temperament tags: `serious, cautious`
49 - **Model:** sonnet
50 - **Patterns & role:** informal review seat — reads each of the five deliverables once drafted and asks "what would we cut," specifically watching for take-home scope creep this track is prone to (Kubernetes manifests, a service mesh, a full observability stack) that reads as impressive but isn't what a take-home needs. One short note per deliverable, folded into the deliverable file rather than a separate debate artifact.
51 - **Persona (draft, 118 words):** Plain-spoken and unimpressed by scale for its own sake, but not identical to Theo — where Theo sizes to the actual problem from experience, Callum's method is adversarial subtraction: he assumes everything in a draft is optional until proven otherwise. Four years as an SRE at a logistics company that got burned by an over-built Kubernetes migration for a service with ten users, two years freelancing infra audits for seed-stage startups telling them what to delete. Comfortable with Docker Compose, basic CI, and reading a Terraform diff for what it actually provisions. Pushes back on anything justified by "best practice" rather than a stated failure mode. Blind spot: has, in his own account, deleted something load-bearing before. Disagreement: states the cut plainly, doesn't fight if overruled.
52
53 ## 3. Debate plan
54
55 - **One Newcomer's-question pass** (Wesley) on 03's service-boundaries entry in `PLANNING.md` and on 02's secrets-inventory/never-log hand-off, run once each lands. This is the pattern `CASTING.md` §5 explicitly scopes to this track, and it's the highest-ROI pattern in the whole project per Marcus's synthesis — it directly tests whether those two hand-offs actually meet the "receiver can act without re-deriving" standard this track depends on.
56 - **Everything else is waste, explicitly:** no adversarial pair, no red/blue, no three hats, no structured written debate, no rotating devil's advocate seat. This track has no security-property decision (that's 02's), no multi-DB abstraction choice (that's 05's), and no authz scoping (that's 03/02's) — the catalog itself names this track's decisions as exactly the kind not worth debating (endpoint shapes, a Dockerfile, a compose file). Bree and Callum's roles above are lightweight per-deliverable notes, not formal debate artifacts, because formalizing them into `decisions/*.md` files for a Dockerfile sketch would be process theater on a three-task track.
57
58 ## 4. Dependencies
59
60 Stable enough to design against means, for each of the three gating inputs:
61
62 - **03 service boundaries** (`../PLANNING.md`, Service boundaries section): stable once Renata states — one binary or two, Go package layout, the config surface (env vars vs. flags vs. file), and the DAO driver-selection mechanism — and marks it as decided rather than a placeholder. It does not need to be implemented in code yet, only unlikely to be rewritten; per `planning-approach.md`, "stable" means that, not "polished."
63 - **02 secrets inventory + never-log list**: stable once it exists as an enumerated list (which secrets, roughly what each protects) plus an explicit never-log list, in a named file this track can cite — not as prose alternatives Theo would have to interpret. Marcus's `planning-approach.md` already commits this as a deferred 02→04 follow-up once the planning pause lifts.
64 - **05 migration approach** (`../05-data-ops/multi-db-strategy.md`): stable once it names the migration tool/mechanism (e.g., a specific Go migration library, embedded SQL files, or a codegen approach) — this track only needs the name and invocation shape to script a pipeline step around it, never the migration content itself, which stays 05's.
65
66 ## 5. Status (updated post-drafting)
67
68 All three gating inputs landed (03 boundaries stable, 02 secrets hand-off v2, 05 migration approach provisional-but-stable-for-04-per-05's-own-note) and Warren gave the go to start against them once the usage reset. All five deliverables drafted: `./containerization-design.md`, `./ci-pipeline.md`, `./local-dev-loop.md`, `./secrets-delivery.md`, `./observability.md`. Bree's capability-case notes (NetworkPolicy adopted, CockroachDB CI job adopted, hot-reload adopted; HPA/service-mesh declined) and Callum's scope-cut notes (isolation-paragraph trim, anti-affinity dropped, KMS row folded, tracing cut to one line) are folded directly into the files, not a separate decisions artifact, per PLAN.md §3's proportionality call. `newcomer-coldread-boundaries-secrets.md` in `./decisions/` covers Wesley's earlier hand-off legibility pass. Wesley also ran a bounded two-turn loan for 01 (Naomi), closed clean.
69 {
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/PLAN.md"
}1 # Track 02 Plan — AI Architecture & Security Architecture (Phase 1)
2
3 Owner: Marcus Ilori. Written in plan mode per `PLAN.md` Phase 1. Once approved it is copied verbatim to `02-ai-security-architecture/PLAN.md`; the `~/.claude/plans/` copy is scratch.
4
5 ## Context
6
7 The org plan (`PLAN.md`) requires every lead to plan in plan mode with four mandatory sections before hiring. Track 02 carries two extra obligations as the AI-architecture owner: ratify or amend the orchestration-pattern catalog in `CASTING.md` §5, and check the harness facts in `PLAN.md` "Verified mechanics" against `research-claude-architecture-best-practices.md` before other leads build on them. The four first-wave deliverables of this track (`threat-model.md`, `api-auth-design.md`, `connector-security.md`, `ai-workflow-narrative.md`) are complete; this plan turns them into debated, second-reviewed, receiver-shaped outputs and adds the org-layer story to the AI narrative. No code anywhere in this plan.
8
9 ## 0. Ratification of `CASTING.md` §5 and the mechanics check
10
11 **Verdict on §5: ratified, with four amendments.** The six protocols are sound and their use/waste assignments match my own planning-approach reasoning. The amendments each target a structural weakness that the palette's own blind-spot column predicts:
12
13 1. **Red team / blue team** — add a mandatory ranking step: the Adversary hands over a *top-five* ranked list of leaves before the blue seat responds (counters "sees threats everywhere, no prioritization" structurally rather than by exhortation). Add an optional one-turn *alternatives* seat for the Tinkerer between red and blue: propose design changes that delete leaves outright — a leaf that cannot exist beats a control. Add a residual-risk scoring rule (below). Point to the attack-tree template in `planning-approach.md` Appendix A.
14 2. **Adversarial pair** — every numbered objection from the Designated Skeptic must end with a one-line "what would change my mind" (counters "objections without a counter-proposal"). The Proposer must answer every objection in writing; it may decline to revise, but not silently.
15 3. **Structured written debate** — the lead's synthesis must state which position was stronger on which point and why; preserving both positions is necessary but not a substitute for a ruling (counters the Consensus Weaver blind spot, which is the PM's own listed archetype).
16 4. **All patterns** — each decision artifact records, in a footer, the model each seat ran on and the number of hire turns consumed, so `ai-workflow-narrative.md` can cite measured cost rather than intent.
17
18 Where the text lives, one source of truth per thing: the four protocol amendments go into `CASTING.md` §5 in place (my permitted scope); the attack-tree *template* — a table shape, not a protocol — goes into `planning-approach.md` as **Appendix A** with a one-line pointer from §5. No new document.
19
20 **Attack-tree template (Appendix A content):** columns `# | Leaf (attack path toward the goal) | Precondition (what the attacker must already have) | Likelihood L/M/H | Impact L/M/H | Blue response: Control or Accepted risk | Owner (02 requirement / 03 implements / 04 mechanism) | Residual L/M/H | Source`. Rules: red ranks the top five first; blue answers every leaf, and an "accepted risk" must say why it is acceptable *for this system*, not in general; the lead assigns Residual with a one-line justification; any Residual **H** must produce either a follow-up Story or an accepted risk visible to Warren in the artifact. The completed table is appended to the surface's design doc (`connector-security.md`); the full positions live in `decisions/<topic>.md`.
21
22 **Mechanics check against the research doc: no contradictions.** Three notes for the record, to be added as a short "Harness constraints" addendum to `planning-approach.md` so it stays the single source of truth for the tiers:
23
24 - Effort: the harness has no per-agent effort key; teammates inherit `high`, and `modelSettings.haiku.effortLevel: low` is the only per-model lever. My sonnet-medium tier therefore collapses to sonnet-high (accepted — errs upward on the tracks that matter), and *sonnet/low research fan-out is unavailable*: research is haiku/low, or sonnet/high when judgment is needed, which is costlier than planned and should be used sparingly.
25 - The per-agent `PreToolUse` hook and the `.claude/agents` frontmatter facts are outside my research doc's scope; I have no primary-source basis to dispute them and record them as verified by team-lead, not by 02.
26 - Research doc §4 (prompt caching) still applies one level down: a hire's `.claude/agents/<slug>.md` is part of that hire's stable prefix. Do not edit a hire's definition while it is running; edit at spawn boundaries.
27
28 ## 1. Deliverables and tasks (Epic: *02 AI & Security Architecture*; one Story each)
29
30 | # | Story | Hand-off produced (file) | Consumer | Depends on |
31 |---|---|---|---|---|
32 | S1 | Ratify/amend `CASTING.md` §5 (four amendments above) and add Appendix A to `planning-approach.md` | `CASTING.md` §5; `planning-approach.md` App. A | all leads, before any red/blue runs | — |
33 | S2 | "Harness constraints" addendum to `planning-approach.md` | `planning-approach.md` | all leads; `ai-workflow-narrative.md` | — |
34 | S3 | Red/blue on the connector token lifecycle → attack-tree table with residual scores | `connector-security.md` (table appended); `decisions/connector-token-lifecycle-redblue.md` (full record) | 03's opus token-lifecycle seat; 04 (any control that is a mechanism) | hires; S1 |
35 | S4 | Adversarial pair on search-API authorization scoping | `decisions/search-authz-scoping.md`; `api-auth-design.md` gains "Objections considered" | 03 (middleware) | hires |
36 | S5 | Structured written debate on the core claim of `ai-workflow-narrative.md` | `decisions/ai-workflow-claim-debate.md` | S9 | hires |
37 | S6 | **02→03 receiver-shaped auth hand-off** — token scheme (client-credentials → JWT, asymmetric signing, claim set `sub/scope/exp/iat/aud`, TTL 5–15 min, no refresh, no denylist), **where validated** (middleware at the API edge; key retrieval), the final scope vocabulary and object-level policy hook from S4, pagination ceiling and per-client rate limit as requirements, audit-log field list, and an explicit "not yours to decide" list | `handoff-03-auth.md` | 03 — Renata scaffolds now, needs this before middleware is finalized | S4 |
38 | S7 | **02→04 secrets inventory + never-log list** — table `secret | used by (API / connector / authz server) | injection requirement | rotation expectation | never-log`; never-log list (vendor password, vendor token, PII field values, raw vendor error bodies, JWT signing private key, client secrets). Assumes **Kubernetes-native Secret delivery on the LAN lab cluster** 04 names as the design-only target (Warren's decision via Theo, `LOG.md`), stated explicitly rather than a generic "managed store"; requirements still hold if the mechanism changes | `handoff-04-secrets.md` | 04 — gates Theo's producer phase | one Skeptic objection turn; no debate |
39 | S8 | Independent second-review pass on the security-graded docs (`threat-model.md`, `api-auth-design.md`, `connector-security.md`) by hires who did not author or blue-team them | `decisions/second-review-security-docs.md`; fixes applied in place | Warren / final consistency pass | S3, S4 |
40 | S9 | Revise `ai-workflow-narrative.md` to cover the org layer — personas, patterns, debates, measured turn counts and models from the decision-artifact footers; incorporate the S5 ruling honestly, whichever way it goes; state plainly that a real external agent (the cluster operator 04 describes) exists in the environment and was deliberately kept out of scope — never contacted, nothing deployed | `ai-workflow-narrative.md` | submission reader | S3–S5 |
41 | S10 | `threat-model.md` maintenance: (a) repoint the stale reference to `05-data-ops/schema/postgres_cockroachdb.sql` (wiped) at the prose schema in `../05-data-ops/multi-db-strategy.md` — verified 2026-09-12 that `auth_method` and `secret / hash_algo / hash_cost` are already there in prose; (b) add one trust-boundary line: an **external operator agent applying manifests to the deployment target** (04's design-only lab cluster) is a privileged actor outside this service — whoever can apply a manifest can read any Secret it mounts — so the assumption "secrets are safe once in the cluster" is named, not silent | `threat-model.md` | 04 | none — can run at Phase 3 start |
42 | S11 | Post-implementation independent review of 03's auth middleware and connector token code (security-graded surfaces only) | `decisions/second-review-implementation.md` | 03 | Warren's code go-ahead — Phase 4+, listed now so it is not forgotten |
43 | T1 (Task) | Deep research, pre-authorized for one topic only if the Adversary or Architect requests it: bearer-token handling and OAuth 2.0 security best current practice (RFC 6750, RFC 9700) to arm S3 with primary sources; haiku/low fan-out run by me | `research-oauth-token-handling.md` | S3 | request from a hire |
44
45 Cross-team asks (not my stories): 04's Newcomer performs the cold read of `handoff-03-auth.md` and `handoff-04-secrets.md` (already the one pattern `CASTING.md` assigns 04); the red/blue on the connector is run **once**, here, with 03's opus seat reading the artifact rather than 03 running a second one — I will flag this to Renata so it is not duplicated.
46
47 ## 2. Hire roster (4; all sonnet; all read-only per `_TEMPLATE.md` — they return prose, I write the artifacts)
48
49 No first name collides with Dana, Naomi, Marcus, Renata, Theo, or Priya. Roster spans serious↔playful and cautious↔bold; two hires are `playful, bold`, contributing to the org-wide minimum of three.
50
51 ### H1 — Tomasz Wrede · Adversary · `playful, bold` · function: attack-tree author
52 Patterns: red/blue (red seat, S3); optional one-turn pre-read on S4 ("what would I do with each scope"). Requester for T1.
53
54 > Mischievous and quick — the reviewer who reads a sequence diagram and immediately asks where he would stand to steal from it, and grins while asking. Decides by enumerating attack paths first and only then asking which ones matter, which he admits is backwards. Pushes back hardest on any claim that a control is "sufficient" until someone names the attacker it stops and the one it does not. Eight years on an internal red team at a large retailer, then four running penetration tests against payment and identity APIs for a consultancy; fluent in token theft, replay, and cache-poisoning paths, less so in formal proofs. Blind spot: he sees threats everywhere and ranks them poorly — left alone he produces forty leaves of equal weight, so make him hand over a top five before anyone answers. Argues cheerfully, concedes fast when a leaf is shown unreachable, and keeps a list of the ones he lost.
55
56 ### H2 — Helena Marsh · Principled Architect · `serious, cautious` · function: controls author and standards reviewer
57 Patterns: red/blue (blue seat, S3); adversarial pair (Proposer, S4); second review of `threat-model.md` and `api-auth-design.md` (S8 — she did not author them and is not blue on them).
58
59 > Measured and exact; in a meeting she speaks last, usually with a section number. Decides from the threat model downward and writes every decision as a record with the alternatives she rejected and why. Pushes back on any control offered without the attack it defeats, on any trade of a security property for developer convenience, and on a citation that points at a blog when a specification exists. Eleven years across a national bank's identity platform and a government PKI programme; deep in OAuth 2.0 and its security best current practice, JOSE and the ways JWT libraries fail, and TLS deployment. Blind spot: over-specification — she designs for threats this take-home does not have and can mistake thoroughness for finished, so pair her with someone asking what the assignment actually needs. Courteous under challenge, unhurried, and changes position when shown evidence, saying so plainly.
60
61 ### H3 — Felix Adebayo · Tinkerer (silly & innovative) · `playful, bold` · function: alternatives generator — **my temperament opposite (required)**
62 Patterns: red/blue (alternatives seat between red and blue, S3 — "what if the connector held no token at all?"); structured written debate (Position A, S5).
63
64 > Playful and fast-associating; he describes a bearer token as a hotel keycard that opens every door until Thursday, and means it as a design question. Decides by sketching three alternatives, one deliberately strange, before defending any, and drops his own without ceremony when someone shows a cleaner failure mode. Pushes back on "that is the standard approach" whenever nobody in the room can say what the standard protects against, and on any diagram with a box labelled "just cache it". Five years building fraud-scoring pipelines at a payments startup, three at a mid-sized identity vendor prototyping passkey flows; Go and Rust, and an unreasonable fondness for property-based tests. Blind spot: novelty bias — he underweights boring, proven controls and needs someone to cite the RFC at him. Warm in disagreement, never sarcastic, delighted to be wrong in an interesting way.
65
66 ### H4 — Ingrid Solano · Designated Skeptic · `serious, bold` · function: standing objector and independent reviewer
67 Patterns: adversarial pair (Skeptic, S4); structured written debate (Position B, S5); one objection turn on the secrets inventory (S7); second review of `connector-security.md` (S8 — she is not blue on it).
68
69 > Dry and direct; in a meeting she is the one asking "compared to what?" before the proposal is finished. Decides only after hearing the strongest case against the room's favourite, and makes that case herself if nobody else will, even when she privately agrees. Pushes back on consensus that arrived too quickly, on scope vocabularies invented before anyone listed the callers, and on evidence that is one number from one vendor. Nine years as a security reviewer on an enterprise SaaS platform, then three chairing design reviews at a healthcare data company where every authorization decision met a regulator. Background in authorization models — RBAC, ABAC, object-level policy — and in reading audit logs after something went wrong. Blind spot: objections without a counter-proposal — she can leave a team knowing what is wrong and not what to do, so every objection carries a "what would change my mind" line. Polite, unyielding on process, quick to record when an objection was answered.
70
71 Registry rows (appended to `CASTING.md` §8 only after "hiring go"): `tomasz-wrede`, `helena-marsh`, `felix-adebayo`, `ingrid-solano`; Track 02; Model sonnet; Persona `02-ai-security-architecture/profiles/<slug>/<slug>.md`; Agent def `.claude/agents/<slug>.md`; Portrait `queued`.
72
73 ## 3. Debate plan
74
75 | Decision | Pattern | Seats | Turns | Artifact |
76 |---|---|---|---|---|
77 | Connector token lifecycle: TTL, at-rest encryption, per-vendor keying, fail-closed, "hold no token" alternative | Red/blue (amended) | Tomasz red → Felix alternatives → Helena blue → Tomasz one rebuttal on the top three → Marcus scores residual | 4 + lead | `decisions/connector-token-lifecycle-redblue.md`; table into `connector-security.md` |
78 | Search-API authz scoping: `profile:search` vs `profile:read:own` vs `profile:read:any`; object-level policy; who gets `profile:search` at all | Adversarial pair (amended) | Helena proposes → Ingrid numbered objections with "what would change my mind" → Helena answers/revises → Marcus rules (optional Tomasz pre-read, 1 turn) | 3 (+1) + lead | `decisions/search-authz-scoping.md`; "Objections considered" into `api-auth-design.md`; feeds S6 |
79 | Core claim of `ai-workflow-narrative.md` — does the multi-agent, persona-driven workflow produce *better design* than one strong session, or is it cost without evidence? (Anthropic's 90.2% figure is for research breadth, not design quality) | Structured written debate (amended) | Felix: it does, and the narrative undersells it → Ingrid: unproven; keep the narrow claim → Marcus rules per point, and the ruling goes into the narrative whichever way it lands | 3 + lead | `decisions/ai-workflow-claim-debate.md` |
80 | Secrets inventory completeness | Single objection turn | Ingrid: what is missing / mis-owned | 1 | inline in `handoff-04-secrets.md` |
81 | Security-graded docs, independent read | Second review | Helena on threat model + API auth; Ingrid on connector security | 2 | `decisions/second-review-security-docs.md` |
82
83 Budget: about 14 hire turns for the whole track, all sonnet/high. **Declared waste — not debated:** the Argon2id hashing decision (settled by OWASP/NIST — cite, do not debate); the OAuth2 grant *type* (already reasoned in `api-auth-design.md`; S4 is about scoping, not the grant); STRIDE as the framework; endpoint paths and JSON bodies (fixed by the assignment); anything about DAO CRUD or Dockerfiles; a three-hats run (no decision in this track has three legitimate optimist/pessimist/pragmatist positions). No pattern gets a second run "to be sure".
84
85 ## 4. Dependencies
86
87 **From 05 (Priya):** the `auth_method` lookup-table shape (`id, name, requires_secret, is_active`; `user_credential.secret / hash_algo / hash_cost`) — **settled** by direct two-party coordination and confirmed present in prose in `multi-db-strategy.md`; my threat model is already method-aware. Nothing further needed from 05 for this track's Phase 3; the Go-shaped contract she is delivering is 03's dependency, not mine.
88
89 **From 01 (Naomi):** `api-connector-design-rationale.md` as the single source of truth for field/endpoint shape — a soft dependency, read before finalizing S6, not before starting.
90
91 **To 03 (Renata):** `handoff-03-auth.md` (S6) — she scaffolds now and needs it before middleware finalization; target: immediately after S4 rules, early Phase 3. The red/blue table (S3) is the requirement set for her opus token-lifecycle seat; target mid Phase 3. One red/blue, run here, read there.
92
93 **To 04 (Theo):** `handoff-04-secrets.md` (S7) — gates his producer phase; it depends on no debate, only one objection turn, so it is the **first** deliverable out after hiring. His Newcomer cold-reads both hand-offs.
94
95 **From 04 / Warren (via team-lead, recorded in `LOG.md`):** the deployment target is a real Kubernetes lab cluster on the LAN operated by an external agent, used as a **design-only** reference. This track assumes Kubernetes-native Secret delivery in S7 and names the operator as a trust boundary in S10. Hard rule inherited: no action on that cluster and no contact with its operator agent, ever, without Warren's explicit go — this track has no reason to touch either.
96
97 **To all leads:** `CASTING.md` §5 amendments and Appendix A (S1) before any lead runs a red/blue; the harness-constraints addendum (S2) before anyone spawns a "sonnet/low" research subagent that will actually run at high.
98
99 **From team-lead:** "hiring go" — nothing in §2 is created or spawned before it.
100
101 ## Files touched after ExitPlanMode (Phase 1 close-out only)
102
103 1. Copy this plan → `02-ai-security-architecture/PLAN.md`.
104 2. `PLANNING.md` — my row only → `plan drafted — awaiting hiring go`, hires `4 / 0 / 0`, planning notes pointer, date.
105 3. `CASTING.md` §5 — the four amendments in place, nothing outside §5.
106 4. `planning-approach.md` — Appendix A (attack-tree template) and the Harness-constraints addendum.
107 5. `02-ai-security-architecture/CLAUDE.md` Status — one line pointing at `PLAN.md` (turn-boundary edit).
108 6. ≤200-word summary with roster to team-lead.
109
110 Not touched until "hiring go": `profiles/`, `.claude/agents/`, `
… truncated (20 KB cap){
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/PLAN.md"
}1 # Track 01 Plan — Product & Industry Research (incl. Design)
2
3 Lead: Naomi Voss (`naomi-voss`). Epic: **01 Product & Industry Research**. Governing files: root `CLAUDE.md`, `../PLAN.md` (org plan, Phase 1), `../PLANNING.md` (my row), `../CASTING.md` (palette, variance rules, persona standard, pattern catalog), `../02-ai-security-architecture/planning-approach.md` (coordination rules, hand-off standard, dependency order). This plan is written in plan mode per Phase 1 and is copied verbatim to `./PLAN.md` on exit; the real gate is team-lead's explicit "hiring go".
4
5 ## Context
6
7 This track's three first-wave deliverables already exist and are complete as drafts: `industry-framing.md` (the "before/after" thesis — the assignment's password baseline is the deliberate "before", LoginID's passkey product is the "after", and the submission's job is to build the before honestly with the seams marked), `personas-use-cases.md` (three personas mapped to the three assignment questions), and `api-connector-design-rationale.md` (the field/endpoint/connector-shape rationale, designated by `planning-approach.md` §2 as the single source of truth 03 implements against). Two research docs (`research-loginid-company.md`, `research-oauth-history.md`) are their evidence base.
8
9 The work this plan schedules is therefore not drafting — it is **stress-testing and hardening**: the framing has never been argued against, the claims have never been audited for evidence, and the hand-off doc has never been read cold by someone who has to act on it. Those three gaps are exactly what the three hires and the debate plan address. 01 is first-wave and upstream of everyone (nothing hard-blocks on it, but 03 should read the rationale before finalizing), so this work should finish before 03 finalizes handlers and the connector interface.
10
11 Model/effort: the lead seat runs at whatever Warren has set for the session; all three hires are pinned `sonnet` (inherit effort); any research fan-out I run is `haiku` (project settings pin it to low). No code anywhere in this track — it is a prose track by definition.
12
13 ## 1. Deliverables & tasks (each = one Jira Story under the 01 Epic)
14
15 Every row names the hand-off file it produces or consumes; acceptance criterion for every row is the hand-off standard from `planning-approach.md` §2: *the receiver can act without re-deriving my reasoning*.
16
17 | # | Story | Produces | Consumed by | Pattern / hire |
18 |---|---|---|---|---|
19 | S1 | **Claims audit of the three framing docs.** Every quantitative or evaluative claim in `industry-framing.md`, `personas-use-cases.md`, `api-connector-design-rationale.md` gets a row: claim, source (file + section or URL), status (verified / asserted / marketing-sourced / softened). I then fix or soften each unsupported claim in place. Runs **first**, so the debate in S2 argues over verified facts. | `decisions/claims-audit.md`; edits to the three docs | Team-lead (README/packet), 02 (`ai-workflow-narrative.md` cites the framing), evaluators | Desmond (Spreadsheet), 1–2 turns |
20 | S2 | **Structured written debate on the central framing** — is the password baseline a deliberate simplification the submission should narrate as "before/after", or a contradiction at a passkey company that the framing over-reads by claiming to know what evaluators "are really testing for"? Position → rebuttal → my synthesis; both positions preserved. `industry-framing.md` §2 and §4 revised to whatever survives. | `decisions/password-baseline-debate.md`; revised `industry-framing.md` | Team-lead (submission thesis), 02 (narrative alignment) | Imogen (Storyteller, position) vs Tobias (Designated Skeptic, rebuttal); Naomi synthesizes. 3 turns + lead |
21 | S3 | **Harden `api-connector-design-rationale.md` as the single source of truth for field/endpoint shape.** Add a closing "Contract summary for 03" section: the two entities with the assignment's exact field names; the `method` discriminator and 1-to-many profile→credential requirement (consistent with the 02/05 lookup-table decision); the endpoint list (`GET /profiles/{id}`, search endpoint with the `GET` query-param vs `POST /profiles/search` recommendation resolved, not left open); the provider-agnostic connector interface shape (`Authenticate`, `FetchIdentity`) with ABC/XYZ as adapters; and an explicit **"03 does not re-derive / out of scope for 03"** list (auth mechanism is 02's, schema modeling is 05's, address field names are fixed by the assignment). Then a newcomer's-question cold read; I fix everything the reader could not act on. | Revised `api-connector-design-rationale.md`; `decisions/rationale-cold-read.md` | **03** (reads before finalizing handlers and connector interface — soft dependency); 05 (§1 only) | Newcomer's question — reader borrowed from 04 (see §4), fallback a context-free haiku subagent. 1–2 turns |
22 | S4 | **Product requirements for search authorization** — a short appendix to `personas-use-cases.md` stating what Persona 2 legitimately needs from search (fields searchable: name, phone, partial match; masked-by-default results; deliberate expand with audit entry; no bulk export; pagination) and what Persona 2 must *not* be able to do, written as input to 02's adversarial-pair run on `profile:search` vs `profile:read:own`. Product requirements only — no scope names, token formats, or policy mechanics. | `personas-use-cases.md` "Search authorization — product requirements" appendix | **02** (input to its authz scoping decision), 03 indirectly via 02's hand-off | Rotating devil's advocate, 1 turn (seat: Tobias) |
23 | S5 | **UX notes for the two surfaces** that would sit on top of this backend — the admin profile-search console (Persona 2) and the onboarding pre-fill flow that triggers the connector (Persona 3). Text only, one page, describing screens and the one design rule each surface enforces (masked-by-default; never showing the partner password). Supports the narrative; no wireframes, no design canvas (see §3, waste). | `ux-notes.md` | Team-lead (packet), evaluators | Rotating devil's advocate, 1 turn (seat: Desmond — "which of these sentences is a claim?") |
24 | S6 | **Newcomer cold read of `industry-framing.md`** — evaluator-legibility test: can a reader with no project context state the thesis and the three seams in their own words after one pass? Fix what they could not. Secondary to S3; runs only if the borrowed reader has a second turn available. | Revised `industry-framing.md`; appended to `decisions/rationale-cold-read.md` | Evaluators, team-lead | Newcomer's question, 1 turn |
25 | T1 | **Track housekeeping** (Jira Task, not a Story): update `./CLAUDE.md` Status and AI-tooling note at a turn boundary (hires, patterns run, models used, research authorized); keep my `PLANNING.md` row current; append my three registry rows to `CASTING.md` after "hiring go"; create `decisions/` directory. | `./CLAUDE.md`, `../PLANNING.md` row, `../CASTING.md` rows | Team-lead, consistency pass | — |
26
27 Order: S1 → S2 → S3 → S4 → S5 → S6 (S6 optional). T1 runs alongside. Every pattern run ends in a file under `01-product-industry-research-design/decisions/` — chat is not the record.
28
29 ## 2. Hire roster (3 hires — matches the suggested cast; I am first in arrival order, so the full palette is available)
30
31 All three: `model: sonnet`, read-only tools (Read, Grep, Glob, Bash), no file ownership — they return prose to me and I write the artifacts. No first name collides with a lead (Dana, Naomi, Marcus, Renata, Theo, Priya) or with each other. Roster spans both temperament axes (serious↔playful, cautious↔bold). Imogen supplies one of the org's required `playful + bold` seats.
32
33 ### 2.1 Imogen Hale — Storyteller — function: narrative counterpart and framing proponent
34
35 - **Archetype:** Storyteller. **Tags:** `playful`, `bold`. **Slug:** `imogen-hale`.
36 - **Function:** holds and argues the framing position in S2 so I can synthesize rather than defend my own thesis; drafts the README-facing one-paragraph version of the thesis; rotating-advocate seat in S3's follow-up.
37 - **Patterns & role:** Structured written debate — *Position* (S2). Rotating devil's advocate — rotating *Objection* seat (S5 alternate).
38 - **Persona (CASTING.md §4 order, 138 words):**
39
40 > Warm, quick-to-metaphor product storyteller who opens a review by asking whose day this changes, and only then looks at the design. Decides by drafting the README paragraph first: if a feature's story can't be told in one paragraph to a support analyst, she treats the design as unfinished. Pushes back on framings only an insider would find persuasive, and on "the evaluators will get it" as a reason to skip the explanation. Eight years as a product manager on a consumer fintech onboarding team, then four writing developer-facing docs and launch narratives at a mid-size API company. Reads API contracts and sequence diagrams comfortably; does not write production code. Blind spot: charming beats correct — she will polish a narrative past the point where the facts still hold it up, so pair her with whoever asks for the number. In disagreement she restates the other side's story better than they told it, then says exactly where hers differs.
41
42 ### 2.2 Desmond Okafor — Spreadsheet — function: claims auditor (my temperament opposite — required by variance rule 3)
43
44 - **Archetype:** Spreadsheet. **Tags:** `serious`, `cautious`. **Slug:** `desmond-okafor`.
45 - **Function:** audits every claim in the three docs for evidence (S1); rotating-advocate seat on the UX notes (S5); the standing "where is the number?" check on both S2 positions before I synthesize.
46 - **Patterns & role:** Claims audit (S1 — a one-hire review, not a catalog pattern; artifact is the table). Rotating devil's advocate — *Objection* seat (S5). Structured written debate — *pre-synthesis fact check*, 1 turn, not a debate participant.
47 - **Persona (147 words):**
48
49 > Dry, precise analyst who arrives with a table and distrusts any sentence carrying an adjective it can't cash. Quiet in a meeting until someone says "measurably" or "large market," then asks for the number, the source, and the date. Decides by keeping a two-column sheet — claim, evidence — and will not move a claim into a deliverable until the evidence column is filled or the claim is softened to what the evidence supports. Pushes back on narrative momentum, unsourced market sizing, and "everyone knows." Ten years in competitive-intelligence and pricing analysis at an enterprise software vendor, then three as a research analyst covering identity and access management at an industry-analyst firm. Treats vendor whitepapers as marketing until proven otherwise; fluent in SQL and spreadsheets, not a programmer. Blind spot: measures only what is measurable — he discounts true qualitative insight when it has no number, so pair him with the storyteller. Unemotional in disagreement; concedes instantly to a source, never to volume.
50
51 ### 2.3 Tobias Lindqvist — Designated Skeptic — function: framing rebuttal and standing objection
52
53 - **Archetype:** Designated Skeptic. **Tags:** `serious`, `bold`. **Slug:** `tobias-lindqvist`.
54 - **Function:** writes the rebuttal in S2 (the "contradiction, not simplification" case, and the "you are projecting intent onto the evaluators" case); first Objection seat in the rotating-advocate runs (S4).
55 - **Patterns & role:** Structured written debate — *Rebuttal* (S2). Rotating devil's advocate — *Objection* seat (S4). Because his archetype's blind spot is objections without alternatives, every turn I give him ends with "and your counter-proposal is:".
56 - **Persona (149 words):**
57
58 > Courteous, contrarian product strategist who argues the other side by default and treats a room in agreement as a room that has stopped thinking. Decides late, after writing the strongest case against the leading option and finding it wanting; his recurring question is "what would have to be true for this to be wrong?" Pushes back on tidy framings, on any claim about what an evaluator "is really testing for," and on extension points built for futures nobody has committed to. Seven years as a strategy consultant to identity and payments vendors, then five as a principal product manager at an authentication company whose passwordless launch he argued against internally and then helped ship. Reads OAuth and WebAuthn specifications closely enough to catch a mischaracterization. Blind spot: objections without a counter-proposal — he can leave a decision weaker but not better, so the lead must ask for his alternative. In disagreement, precise and unhurried; changes his mind visibly when the argument is better.
59
60 ### 2.4 Registry rows to append to `CASTING.md` §8 after "hiring go"
61
62 ```
63 | imogen-hale | Imogen Hale | 01 | narrative counterpart / framing proponent | Storyteller | playful, bold | sonnet | structured written debate: position (S2); rotating advocate: objection (S5 alt) | 01-product-industry-research-design/profiles/imogen-hale/imogen-hale.md | .claude/agents/imogen-hale.md | queued |
64 | desmond-okafor | Desmond Okafor | 01 | claims auditor (lead's opposite) | Spreadsheet | serious, cautious | sonnet | claims audit (S1); rotating advocate: objection (S5); debate fact-check (S2) | 01-product-industry-research-design/profiles/desmond-okafor/desmond-okafor.md | .claude/agents/desmond-okafor.md | queued |
65 | tobias-lindqvist | Tobias Lindqvist | 01 | framing rebuttal / standing objection | Designated Skeptic | serious, bold | sonnet | structured written debate: rebuttal (S2); rotating advocate: objection (S4) | 01-product-industry-research-design/profiles/tobias-lindqvist/tobias-lindqvist.md | .claude/agents/tobias-lindqvist.md | queued |
66 ```
67
68 Hire mechanics follow `CASTING.md` §6 exactly (text-first `write_profile.py --output file --assets tracked --root <this dir>`, `display.autostart: false`, image path pre-declared, no `CLAUDE.md` marker; agent def from `_TEMPLATE.md`; spawn with the fixed three first actions). Portrait prompts will be head-and-shoulders studio headshots in the same style as the leads', written into each persona's `generation` block; rendering is team-lead's queue, not mine.
69
70 ## 3. Debate plan — which decisions get which pattern, and what is waste
71
72 **Runs (in order):**
73
74 1. **Claims audit** (S1, Desmond, 1–2 turns) — not a catalog pattern but the precondition for one: the S2 debate must argue over claims that have been checked. Artifact `decisions/claims-audit.md`.
75 2. **Structured written debate** (S2, 3 turns + synthesis) on the one decision that is genuinely mine and genuinely contestable: *is the password baseline a deliberate simplification to narrate as "before/after", or a contradiction at a passkey company, and does the framing over-reach by asserting what LoginID "is really testing for"?* Imogen writes the position (≤600 words), Tobias the rebuttal (≤600 words, ending with a counter-framing), Desmond a ≤200-word fact-check of both, then I synthesize. The synthesis is binding for `industry-framing.md` §2 and §4 and for the one-paragraph thesis the packet uses. Both positions stay in the record — LoginID is grading the reasoning, and a preserved dissent is better evidence of reasoning than a clean conclusion.
76 3. **Newcomer's question** (S3 primary, S6 secondary, 1–2 turns each) — the highest-ROI pattern in the catalog and the one that directly tests the hand-off standard. Primary target is `api-connector-design-rationale.md` because that is the file 03 has to *act* on; the framing doc is secondary because evaluators only have to *understand* it. Reader: 04's Newcomer hire, borrowed under the bounded two-party exception in `planning-approach.md` §2 (see §4). Fallback if the loan is not available when S3 is ready: a `haiku` subagent I spawn with **no project context except the file itself** — a genuinely cold reader, cheap, and within a lead's research-fan-out authority.
77 4. **Rotating devil's advocate** (S4, S5; 1 turn each; seat rotates Tobias → Desmond → Imogen so nobody becomes "the negative one") on the small product calls: the search-authorization product requirements (S4), the UX notes (S5), and — if there is a spare turn — whether `industry-framing.md` §3 (agentic identity / AAuth) earns its place at all or is scope creep dressed as context.
78
79 **Explicitly not debated (waste):**
80
81 - Anything the assignment text fixes: endpoint paths, JSON bodies, the `/identity` address field names, the two entities and their fields.
82 - The authn/authz *mechanism* for the API — 02's; I supply product requirements (S4) and stop.
83 - How `user_credential.method` is *modeled* — 05's, already coordinated with 02 as a lookup table; my doc asserts only that it is a real discriminator with room to grow.
84 - The OIDC `address`-claim observation in the rationale — that is a fact check (one `haiku`/low lookup of OpenID Connect Core 1.0 §5.1.1), not a debate.
85 - Red team/blue team, three hats, adversarial pair — built for security and engineering choices I do not own. Zero runs in this track.
86 - Wireframes, a design canvas, or any visual UX artifact — this is a backend assignment; `ux-notes.md` is text, one page, and stops there.
87 - Deep research to *decorate* the narrative (market-size figures, signup drop-off percentages). If a claim survives the audit only with a number nobody has, the fix is to soften the claim, not to fund a research pass.
88
89 **Deep research I pre-authorize (leads only, per `CASTING.md` §3):** exactly one item — verify the OIDC Core `address` claim member names (`street_address`, `locality`, `region`, `postal_code`, `country`) against the specification, because 03 may reuse those names in Go structs and the rationale currently asserts the match from memory. One `haiku`/low subagent, output to `research-oidc-address-claim.md` with the source URL, recorded in my `PLANNING.md` row. Any other request from a hire is decided against the test in §3: does the answer change a decision another track depends on? For this track, almost nothing does.
90
91 ## 4. Dependencies
92
93 **Nothing hard-blocks on 01.** First wave, in parallel with 02 and 05 (`planning-approach.md` §2). My debates should complete before 03 finalizes handlers and the connector interface — that is a scheduling preference, not a block on 03's scaffolding.
94
95 **Provides:**
96
97 | To | File | Nature |
98 |---|---|---|
99 | 03 | `api-connector-design-rationale.md` (after S3) | **Soft dependency** — 03 reads it before finalizing handler shapes and the connector interface, not before starting scaffolding. It is the single source of truth for field/endpoint shape; 03 does not re-derive it. |
100 | 05 | `api-connector-design-rationale.md` §1 | Read-once check that "method as discriminator" and "1-to-many profile→credential" match `multi-db-strategy.md`. Already consistent with the 02/05 lookup-table decision. |
101 | 02 | `personas-use-cases.md` search-authorization appendix (after S4) | Product input to 02's adversarial-pair run on `profile:search` vs `profile:read:own`. |
102 | Team-lead / 02 | `industry-framing.md` (after S2), `decisions/password-baseline-debate.md` | The submission thesis the README and `ai-workflow-narrative.md` should tell consistently; the consistency pass checks this. |
103
104 **Needs:**
105
106 | From | What | Why | Timing |
107 |---|---|---|---|
108 | 02 (Marcus) | A one-message read-only confirmation that the product requirements I assert in rationale §2 (masked-by-default results, independent rate-limiting and pagination for search, every search attributable/auditable) are compatible with `api-auth-design.md` — or the specific change so my doc does not contradict his. | Two tracks must not disagree about what search returns. | Before S3 closes. |
109 | 02 (Marcus) | The 02→04
… truncated (20 KB cap){
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/ux-notes.md"
}1 # UX Notes — Two Surfaces on Top of This Backend 2 3 Prepared for the `01-product-industry-research-design` track. See `../CLAUDE.md` for the assignment and `./CLAUDE.md` for this track's scope: this is light-touch, backend-supporting narrative, not a design deliverable in its own right — no wireframes, no design canvas. Text, one page, describing screens and the one design rule each surface enforces. 4 5 ## Surface 1: the admin profile-search console (Persona 2) 6 7 **Who uses it:** the support/ops analyst from `personas-use-cases.md`, resolving a ticket or investigating a flagged transaction. 8 9 **Screens:** 10 11 - **Search screen.** A single form: name, phone, partial-match toggle. No blank-query submit — the form requires at least one field filled before the search button is enabled, matching the "no unconstrained query" product requirement. 12 - **Results list.** Each row shows masked/partial PII by default (e.g., a partially redacted phone number, a name, no address) and an "expand" affordance per row, not a bulk-select checkbox anywhere on the screen. 13 - **Expanded record view.** Full PII for one record, reached only through the per-row expand action. This is a different screen state, not a hover-to-reveal or an inline toggle on the results list — the extra navigation step is deliberate and consistent with the backend treating expanded access as a distinct, auditable event. 14 15 **The one design rule this surface enforces:** masked-by-default, expand-is-a-distinct-action. Every other screen decision — no bulk-select, expand requiring its own navigation step, no "export" button anywhere — is downstream of that one rule, not a separate design choice each time. 16 17 ## Surface 2: the onboarding pre-fill flow (Persona 3) 18 19 **Who uses it:** a new user signing up for a service that offers "sign in with a partner provider" instead of a blank registration form, per Persona 3 in `personas-use-cases.md`. 20 21 **Screens:** 22 23 - **Provider choice.** A short list of partner providers (ABC, XYZ, ...), each a single button — the user picks one, consistent with the connector being provider-agnostic on the backend. 24 - **Partner credential entry.** Username/password fields, framed as "sign in to `<provider>`," not the service's own login form — visually distinct, to signal whose credential this is. 25 - **Pre-filled review screen.** Name, phone, and address arrive already filled in from the `/identity` response; the user reviews and confirms rather than retyping. Fields are editable, not read-only — the provider is a starting point, not an irrevocable source of truth for this signup. 26 27 **The one design rule this surface enforces:** the partner's password is never visible to, or stored by, this service past the token exchange — the review screen never displays a password field, partner or otherwise, and nothing on this screen persists the credential entered on the previous one. That is the UI-level expression of the connector's `/auth` → `/identity` two-step: a short-lived token stands in for the password everywhere past the first screen. 28
{
"file_path": "/Users/warrenthompson/.claude/projects/-Users-warrenthompson-Source-LoginID/47cb5423-3173-4f73-9e8a-3e2842e70675/tool-results/bprzddpi6.txt"
}1 === footer tails (01 + 05 handoff-legibility) ===
2 --- 01-product-industry-research-design/decisions/claims-audit.md
3
4 ## Outcome
5
6 Two unsourced quantitative-flavored claims and two evaluator-intent claims softened in place across `industry-framing.md` and `personas-use-cases.md`; one claim (ROPC status) strengthened to match its actual source. All other claims in the three docs were already properly sourced or correctly hedged as vendor marketing — no further changes needed. Nothing required deep research; S2 (the structured written debate on the password-baseline framing) can now proceed arguing over claims that have been checked.
7 --- 01-product-industry-research-design/decisions/password-baseline-debate.md
8
9 But Imogen's underlying point survives, relocated: naming the gap, building it correctly, and marking `user_credential.method` and the connector interface as real extension points is a genuinely better submission than either pretending the gap is invisible or over-engineering a WebAuthn flow nobody asked for. That claim doesn't require reading LoginID's intent — it's true of a password-credential baseline built by *any* competent CIAM engineer, for *any* client, which is Tobias's own point.
10
11 **Ruling:** `industry-framing.md` §2 and §4 are revised (this synthesis, applied directly) to drop every claim that the assignment's password baseline is LoginID's own deliberate narrative choice, and to re-ground the same engineering conclusions — hash/salt properly, `method` as a real discriminator, connector as a provider-agnostic interface — as industry-standard good practice that happens to point toward the same "after" LoginID sells, stated as forward-looking architecture rather than as an inference about the assignment author. Both positions are preserved above in full; the synthesis is binding for the document text, not a claim that Imogen's position was wrong to make — the debate is exactly why the framing is now stronger.
12 --- 01-product-industry-research-design/decisions/rationale-cold-read.md
13
14 **Reader's restatement (turn 2 of the loan, relayed via `04-infra-devops-47`, Wesley's environment has no cross-session tool):** the submission builds a correctly-done, industry-standard password/legacy-connector baseline — not a mistake to silently "fix" with FIDO2 — but deliberately leaves visible extension seams (`user_credential.method`, the connector's provider interface) toward what LoginID actually sells (passkeys, OIDC federation, eventually agent-identity delegation), without a rewrite. Naming that gap is itself the signal of market fluency for evaluators. He further correctly identified that the document's current version walked back an earlier claim that LoginID deliberately chose this gap (dropped as unfalsifiable per the S2 debate), and now frames password + static connector as the conventional "before" any CIAM backend starts from, with FIDO2/OIDC/agent-identity as the "after" it evolves toward — regardless of what the assignment author meant. His one caveat: he inferred the S2 resolution from this document's own summary of it, not from reading `decisions/password-baseline-debate.md` directly.
15
16 **Outcome:** thesis and the before/after-vs-author-intent distinction both restated correctly and completely — no fix needed. His caveat is itself evidence the doc's summary of S2 is legible enough to stand in for the full debate record for a reader who only needs the thesis, which is exactly what `industry-framing.md` is for (evaluators read this file; they don't need to open `decisions/`). Loan closed at 2 of 2–4 turns used — no further turns needed for this track.
17 --- 05-data-ops/decisions/handoff-legibility-05-03.md
18
19 ---
20
21 *Pattern footer (`../../CASTING.md` §5 ⟨02⟩). Seats: cold reader Saoirse Byrne — sonnet, 2 turns (read, tail); parallel cold reader Wesley Okonkwo (04, on loan, ≤4 turns authorized) — sonnet, 1 turn, relayed by the 04 lead; revisions and rulings Priya Nandakumar — Claude Opus 5. Hire turns consumed: 3. Blocking items raised: 14. Accepted: 14. Declined: 0. Tail findings: 3, all adopted.*
22
23 === version labels ===
24 PLAN.md:134:3. **Config surface and secrets** — `PLANNING.md` Service boundaries vs. `02/handoff-04-secrets.md` v1 vs. `04/secrets-delivery.md` vs. `04/local-dev-loop.md` (compose must not leak a secret the inventory forbids in env). `DB_DSN_FILE` precedence and the literal issuer/audience values must match everywhere.
25 02-ai-security-architecture/handoff-04-secrets.md:3:Status: **v1** — Ingrid Solano's S7 objection turn is answered below and folded into the tables. Awaiting 04's Newcomer (`wesley-okonkwo`) cold read. Receiver: Theo Bergman, `../04-infra-devops/`. Written to the project's hand-off standard: 04 should be able to act on this without re-deriving the reasoning in `threat-model.md`, `api-auth-design.md`, or `connector-security.md`.
26 02-ai-security-architecture/handoff-03-auth.md:3:Status: **v2** — updated after S8's independent review of `api-auth-design.md` and `connector-security.md` surfaced fixes that affect this hand-off; both already relayed to you by chat, folded into the durable file here per this project's hand-off standard. Receiver: Renata Cole, `../03-engineering-delivery/`. Written so 03 should be able to scaffold and build the middleware without re-deriving the reasoning in `api-auth-design.md`, `connector-security.md`, or the S3/S4/S8 decision records. Full reasoning, if wanted: `api-auth-design.md`, `decisions/search-authz-scoping.md`, `decisions/connector-token-lifecycle-redblue.md`.
27 02-ai-security-architecture/CLAUDE.md:51:Phase 2 (execution): all four hires spawned and live as named teammates in this session. Completed: **S7** — `handoff-04-secrets.md` v1, Ingrid Solano's objection turn folded in (four objections, all accepted), sent to and cold-read by 04's Newcomer, two gating items fixed. **S3** — red/blue on the connector token lifecycle (Tomasz Wrede red, Felix Adebayo alternatives, Helena Marsh blue, Tomasz rebuttal, Marcus residual scoring); `connector-security.md` §1 rewritten to a no-cache-by-default posture with two hardened implementation controls (fresh auth header per request, explicit fetch-use-zeroize boundary); attack-tree table appended; full record in `decisions/connector-token-lifecycle-redblue.md`; residual all L except one accepted M (bearer-token replay, no vendor PoP lever), no follow-up Story required; handed to 03's opus token-lifecycle seat. **S4** — adversarial pair on search-API authz scoping (Helena proposes, Ingrid five numbered objections, Helena revises all five, Marcus rules); `api-auth-design.md` gained a finalized scope vocabulary, provisioning bars, object-level policy hook, decomposition-resistant rate limiting, and lifecycle-triggered revocation; full record in `decisions/search-authz-scoping.md`. **S6** — `handoff-03-auth.md` written and sent to Renata Cole (03): token scheme, validation point, S4's scope vocabulary, audit-log fields, "not yours to decide" list.
28 04-infra-devops/secrets-delivery.md:3:Design-only. This document implements the delivery mechanism for what `../02-ai-security-architecture/handoff-04-secrets.md` v2 specifies must be a secret and why — this track doesn't decide what's a secret, only how it reaches a running process. Row numbers below refer to that hand-off's inventory table.
29 04-infra-devops/secrets-delivery.md:37:*AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2. Bree's (capability-case) and Callum's (scope-cut) per-deliverable notes reviewed and folded in inline above, per PLAN.md §3.*
30 04-infra-devops/backlog.md:32:- [ ] 02 (the hand-off's author) confirms this implementation matches `handoff-04-secrets.md` v2's inventory without needing to re-explain any row.
31 04-infra-devops/observability.md:44:*AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2's never-log list. Bree's (capability-case) and Callum's (scope-cut) per-deliverable notes reviewed and folded in inline above, per PLAN.md §3.*
32 04-infra-devops/containerization-design.md:5:Inputs this depends on: `../PLANNING.md` Service boundaries (03, stable), `../02-ai-security-architecture/handoff-04-secrets.md` v2 (02, stable), `../05-data-ops/migration-approach.md` (05, **final**).
33 04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md:8:2. `../02-ai-security-architecture/handoff-04-secrets.md` v1 (owned by 02/Marcus Ilori).
34 04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md:43:04 proceeds on `handoff-04-secrets.md` v2, still gated on 05's migration approach before drafting any of the five deliverables.
35
36 === root/track CLAUDE.md Status lines ===
37 04-infra-devops/CLAUDE.md:35:## Status
38 04-infra-devops/CLAUDE.md-36-
39 04-infra-devops/CLAUDE.md-37-No infra work has started. Do not begin until the engineering track's service boundaries are defined enough to containerize and deploy against.
40 CLAUDE.md:67:## Status
41 CLAUDE.md-68-
42 CLAUDE.md-69-Design phase complete for all five tracks (see each track's `CLAUDE.md` Status). All implementation code deliberately wiped 2026-09-12; the project is in the **planning phase** described in `PLAN.md`: Phase 0 scaffolding done, Phase 1 (leads plan in plan mode and propose hires) next, then hiring, in-team debates, a cross-track consistency pass, and Jira stories in project `LT`. No code until Warren's explicit go-ahead.
43 03-engineering-delivery/CLAUDE.md:34:## Status
44 03-engineering-delivery/CLAUDE.md-35-
45 03-engineering-delivery/CLAUDE.md-36-No implementation has started. This track's CLAUDE.md is scaffolding only — do not begin writing code until the product, security, infra, and data-ops tracks have their designs in place to implement against.
46
47 === cursor/offset ===
48 02-ai-security-architecture/api-auth-design.md:59:- **Cursor-based pagination**, not offset — offset walks turn a capped page size into full-table enumeration. Default page 20, hard ceiling 50.
49 02-ai-security-architecture/handoff-03-auth.md:37:- **Cursor-based pagination only** — no offset-based paging. Default page size 20, hard ceiling 50.
50 02-ai-security-architecture/decisions/search-authz-scoping.md:20:**Pagination/rate limits:** cursor-based (not offset), default page 20 / ceiling 50; rate limits 60 req/min for `read:own`/`read:any`, 10 req/min for `search`, with alerting on sustained near-limit usage.
51 05-data-ops/PLAN.md:34:The one genuinely open design question in the contract, and the one with the most ways to be quietly wrong. Decides: the `ProfileQuery` field set and which fields are optional; match semantics per field (name partial/fuzzy, phone exact-or-prefix on the E.164-normalized value, region/country exact); how empty and multiple filters combine (AND, and whether a wholly empty query is a legal "list everything" or an error); pagination as limit/offset versus a keyset cursor, with a maximum page size; sort order and whether it is stable enough for offset pagination to be meaningful at all; and what `total` means — total matching rows, or rows on this page. Also settles how the documented SQLite parity gap surfaces to a caller: identical results with worse performance, or ranked-vs-unranked differences a caller could observe.
52 05-data-ops/multi-db-strategy.md:191: Offset int // 0..10_000
53 05-data-ops/decisions/search-query-shape.md:20: Offset int // 0..10_000
54 05-data-ops/decisions/search-query-shape.md:36:- **Pagination.** Limit/offset. Max page 100, default 50, offset ceiling 10 000. `Limit < 0` or `> 100`, and `Offset < 0` or `> 10_000`, return `ErrInvalidQuery`. Keyset rejected as designing for an uncommitted dataset size. Stated cost: offset pages are unstable under concurrent inserts — a row can shift or repeat across pages.
55 05-data-ops/decisions/search-query-shape.md:37:- **`total`.** Exact count of all matching rows, ignoring limit/offset, via a second `COUNT(*)` in no wrapping transaction, so it may disagree with the page by a row under concurrent writes. **`total` is a magnitude, not a promise of reachability** — it can exceed what the offset ceiling allows a caller to page to.
56 05-data-ops/decisions/search-query-shape.md:65:| 5 | `total` can report a count the offset ceiling forbids reaching | **Accepted** — `total` restated as a magnitude |
57
58 === phone partial/prefix ===
59 05-data-ops/multi-db-strategy.md:9:- **`ProfileRepository`** — `Get(ctx, id) (*UserProfile, error)`; `Search(ctx, query) (results []UserProfile, total int, error)` where `query` filters by name (partial/fuzzy), phone (exact/prefix), region, country, plus limit/offset for pagination; `Create(ctx, *UserProfile) (*UserProfile, error)`; `Update(ctx, *UserProfile) (*UserProfile, error)` implemented as a portable upsert; `Delete(ctx, id) error`.
60 05-data-ops/multi-db-strategy.md:187: Phone *string // EXACT match on the E.164-normalized value (not prefix)
61 05-data-ops/multi-db-strategy.md:220:**`Update` and `Upsert` are FULL REPLACEMENTS, not partial patches.** This is the single most consequential thing in this document and it was missing from the first draft. Every column is written from the struct you pass. **A nil `Phone` sets the stored phone to NULL; it does not leave the existing value alone.** A caller wanting patch semantics must read, modify, and write back. Stated this loudly because both readings are reasonable and the wrong guess silently destroys data.
62 05-data-ops/multi-db-strategy.md:413:| B-Tree on `phone` | all | `Search` phone filter. **Exact match only** — the `ProfileQuery.Phone` contract is exact, and the first draft's "exact/prefix" here contradicted it. The index would support prefix matching; the contract does not offer it. |
63 05-data-ops/PLAN.md:34:The one genuinely open design question in the contract, and the one with the most ways to be quietly wrong. Decides: the `ProfileQuery` field set and which fields are optional; match semantics per field (name partial/fuzzy, phone exact-or-prefix on the E.164-normalized value, region/country exact); how empty and multiple filters combine (AND, and whether a wholly empty query is a legal "list everything" or an error); pagination as limit/offset versus a keyset cursor, with a maximum page size; sort order and whether it is stable enough for offset pagination to be meaningful at all; and what `total` means — total matching rows, or rows on this page. Also settles how the documented SQLite parity gap surfaces to a caller: identical results with worse performance, or ranked-vs-unranked differences a caller could observe.
64 05-data-ops/decisions/search-query-shape.md:16: Phone *string // optional: exact match on the E.164-normalized value
65 05-data-ops/decisions/handoff-legibility-05-03.md:23:1. **`Update`: full replace or partial patch — undefined.** Nothing in the contract answered whether passing a profile with `Phone` nil NULLs the stored column or leaves it. Both readings are defensible; the wrong guess silently destroys data. *Ruled: full replacement, stated in bold. Patch semantics are read-modify-write at the caller.* She called this the single most consequential gap and was right.
66 05-data-ops/decisions/handoff-legibility-05-03.md:37:`Create` and the `ID` field contradicted each other (§2 said the DAO generates it, §3 returned `ErrAlreadyExists` "if the ID is taken") — ruled: caller passes `""`, non-empty is `ErrInvalidArgument`. Ownership of `CreatedAt`/`UpdatedAt` unstated — ruled: DAO-owned, ignored on input. `total` never defined in the contract despite `PLAN.md` naming it as something S2 had to settle — the intent-versus-text gap; now defined as all matching rows ignoring limit/offset, and as a magnitude rather than a promise of reachability. `Phone` match semantics contradicted between the struct comment ("exact") and the index table ("exact/prefix") — ruled exact. All-nil `ProfileQuery` legality unstated — ruled legal. `Delete` on a missing row unstated — ruled `ErrNotFound`. `GetByUsername` case-folding ownership unstated — ruled DAO-side. The SQLite `GLOB` phone constraint was left as "a GLOB equivalent" — she correctly noted that inventing it is a design decision, not an implementation detail; it is now given in full.
67
68 === expand / mask in 02 & 03 ===
69
70 === caller_referral / caller-role / reason_code storage in 05 ===
71
72 === deletion_log / sweep / DeleteExpired in 03 & contract ===
73 PLANNING.md:15:| 05 Data Ops | Priya Nandakumar | **plan approved — all six stories closed** | — | — | `05-data-ops/PLAN.md`; 6 stories S1–S6, S1 = Go-shaped contract in `multi-db-strategy.md` (critical path); S4 `migration-approach.md` + S5 retention table unblock 04; `migration-approach.md` drafted for 04 (R1–R5; single-runner requirement is platform-enforced — pg_advisory_lock is unavailable on CockroachDB), pending one objection turn; deep research authorized+run (lead): `05-data-ops/research-cockroachdb-postgres-semantics.md` — one Go package CAN serve Postgres+CockroachDB but needs an engine-aware retry/error seam (CRDB SERIALIZABLE returns client-retryable 40001; sentinel translation must be SQLSTATE-only); corrects R2 of `migration-approach.md`; S3 ruled `05-data-ops/decisions/multi-db-abstraction.md` — per-driver confirmed, but on reviewability not cost (only 2 real conditionals, doc's rejection reasoning corrected); codegen is orthogonal, 03's choice; **requirement on 03: one conformance suite run against every backend** — SQLite `LIKE` is case-insensitive vs Postgres case-sensitive, so the documented parity gap is a correctness gap, not a performance one; S2 ruled `05-data-ops/decisions/search-query-shape.md` — 8/8 objections accepted; adopted principle: **every caller-visible guarantee names an enforcement site (DDL constraint, index, normalization, or conformance test) — one with no site is a hope, not a guarantee**; **S1 Go-shaped contract stable in `05-data-ops/multi-db-strategy.md`** — 03 may start the DAO; S6 cold read (Saoirse, deliberately kept unfamiliar) returned 14 blocking items, all accepted and fixed: `Update`/`Upsert` are FULL REPLACES (nil NULLs the column), `Upsert` conflicts on id only (identity resolution is the connector's problem), error translation revised to structured codes + our own named constraints (SQLite has no SQLSTATE), `ErrInvalidArgument` added for CHECK violations, `is_active` enforcement site named; S5 closed in `05-data-ops/pii-governance.md` — retention windows (24mo direct / 30d cache / 7d orphan, all proposed pending product-legal), deletion mechanism, and the health check that matters: **alert on age-of-oldest-surviving-row per class, since a stopped sweep produces no error at all**; `deletion_log` added to the schema, PII-free by construction; S4 final and delivered to 04 (`migration-approach.md`) — objection turn overturned R2's *reasoning* while the requirement stood: a txn-scoped advisory lock releases between goose files, so it cannot cover a multi-file run; factory mismatch closed with 03 direct (two-party): `dao.Repository` composite exposing `Profiles()`/`Credentials()`/`Methods()`, `dao.New` signature unchanged — cited in S1 | 4 / 4 / 4 | 2026-09-12 |
74 05-data-ops/multi-db-strategy.md:392:### `deletion_log`
75 05-data-ops/multi-db-strategy.md:400:| `source` | `string` | `TEXT` | `TEXT` | NOT NULL | the deleted profile's class, so a sweep can be audited per class |
76 05-data-ops/multi-db-strategy.md:401:| `reason` | `string` | `TEXT` | `TEXT` | NOT NULL | `CHECK (reason IN ('retention_sweep','subject_request'))`, named `ck_deletion_log_reason` |
77 05-data-ops/multi-db-strategy.md:402:| `external_ref` | `*string` | `TEXT` | `TEXT` | NULL ok | the subject's request reference; NULL for sweeps |
78 05-data-ops/multi-db-strategy.md:433:11. **`deletion_log` carries no PII.** It exists to prove a deletion happened after the row
… truncated (20 KB cap){
"command": "git status --short && echo \"=== backlog.md ===\" && cat -n 04-infra-devops/backlog.md && echo \"=== kid defined anywhere in 02? ===\" && grep -n '`kid`' 02-ai-security-architecture/*.md | cut -c1-120 && echo \"=== api-auth-design mentions mask/expand? ===\" && grep -c -i \"mask\\|expand\" 02-ai-security-architecture/api-auth-design.md 02-ai-security-architecture/handoff-03-auth.md && ls decisions 2>/dev/null || echo \"(root decisions/ does not exist yet)\"",
"description": "Check working tree, the new backlog file, and two citation facts"
}M 05-data-ops/CLAUDE.md M LOG.md M PLAN.md M PLANNING.md ?? 01-product-industry-research-design/backlog.md ?? 02-ai-security-architecture/backlog.md ?? 03-engineering-delivery/backlog.md ?? 04-infra-devops/backlog.md ?? 05-data-ops/backlog.md === backlog.md === 1 # Track 04 — Infra & DevOps Backlog 2 3 Track: 04-infra-devops. Lead: Theo Bergman. Epic: "Infra & DevOps: Containerization, CI/CD, and Operations" (key: TBD, team-lead fills in on transcription). 4 5 Authored now per Phase 4; will be re-checked against the consistency pass's cross-cutting findings (`PLAN.md` §"Cross-cutting — enforcement sites") once that report lands, particularly for Story 6 below. All stories carry `no-code-yet` — Warren's Phase 4 go covers backlog authoring, not implementation; a separate, explicit code go-ahead is still required before any `.go`, Dockerfile, CI config, or compose file is written for real. 6 7 ## Story 1 — Containerize `api-service` and `idp-connector` 8 9 **Description:** Build the two multi-stage Go images (distroless final stage, non-root, read-only root filesystem) and the Deployment/Service/NetworkPolicy manifests targeting the lab cluster, per `./containerization-design.md`. Covers both binaries' Dockerfiles, resource requests/limits sized per service (CPU-bound `api-service` vs. I/O-bound `idp-connector`), the migration `Job` (parallelism 1, gated by the CI pipeline's exclusivity mechanism, not Kubernetes ordering alone), and a default-deny `NetworkPolicy` on `idp-connector` so its "no inbound Service" isolation claim actually holds against the namespace's default-allow network. 10 11 **Acceptance criteria:** 12 - [ ] The receiving track (whoever implements/reviews this) can act on the manifests without re-deriving the reasoning in `containerization-design.md`. 13 - [ ] Both images build from a pinned Go version, produce a static binary, and contain nothing but the binary (+ CA certs for `idp-connector`) — enforcement site: multi-stage Dockerfile's final `COPY` list. 14 - [ ] Neither Pod can write to its own root filesystem at runtime — enforcement site: `securityContext.readOnlyRootFilesystem: true`. 15 - [ ] `idp-connector` is unreachable except from `api-service` — enforcement site: the `NetworkPolicy` ingress rule scoped to `api-service`'s pod selector. 16 - [ ] A stuck `idp-connector` cannot starve `api-service` if colocated on one node — enforcement site: `resources.limits` on both Pod specs. 17 - [ ] The migration `Job` cannot start a second concurrent run — enforcement site: the CI pipeline's concurrency-gated deploy stage (Story 3), not a database lock. 18 19 **Depends on:** 03 — `cmd/api-service` and `cmd/idp-connector` build as real binaries (service boundaries already stable, per `PLANNING.md`); 05 — final migration tooling name for the `Job`'s invocation (already final, `05-data-ops/migration-approach.md`). 20 21 **Model/effort:** Sonnet, medium. 22 23 **Type:** Story. 24 25 **Labels:** `track-04`, `no-code-yet`. 26 27 ## Story 2 — Deliver secrets to both services per 02's inventory 28 29 **Description:** Implement the Kubernetes-native `Secret` delivery mechanism in `./secrets-delivery.md`: file-mounted secrets keyed by name only in manifests, RBAC scoped per-service (and, for the JWT signing key, scoped to the issuing code path specifically, not `api-service`'s verification path), cluster-level encryption-at-rest for the `Secret` resource type, and the migration-runner credential's backend-specific provisioning (`CREATE EXTENSION pg_trgm` rights on PostgreSQL only, nothing extra on CockroachDB, no privilege model on SQLite) kept distinct from the runtime service credential (DML-only, no DDL, per 05's R4). 30 31 **Acceptance criteria:** 32 - [ ] 02 (the hand-off's author) confirms this implementation matches `handoff-04-secrets.md` v2's inventory without needing to re-explain any row. 33 - [ ] No `Secret` value appears in a manifest, image layer, build arg, or CI log — enforcement site: manifests reference names only; CI's `gitleaks` stage (Story 3). 34 - [ ] No ServiceAccount can `get`/`list` a `Secret` it doesn't mount — enforcement site: per-service `Role`/`RoleBinding` scoped to named `Secret`s. 35 - [ ] The migration-runner credential and the runtime service credential are provisioned as two distinct `Secret`s, never one — enforcement site: separate `Secret` objects, separate `Role`s, per 05's R4. 36 - [ ] `Secret` resources are encrypted at rest in etcd — enforcement site: cluster `EncryptionConfiguration`. 37 38 **Depends on:** 02 — `handoff-04-secrets.md` (received, v2, final); 05 — `migration-approach.md` R4 (final). 39 40 **Model/effort:** Sonnet, medium. 41 42 **Type:** Story. 43 44 **Labels:** `track-04`, `security-graded`, `no-code-yet`. 45 46 ## Story 3 — CI/CD pipeline: build, lint, test, scan, migrate, describe-only deploy 47 48 **Description:** Stand up the pipeline in `./ci-pipeline.md`: build/lint/test(`-race`, three DB targets: SQLite, Postgres, CockroachDB)/security-scan/image-build on every PR; push-images/migration-job/describe-only-deploy on merge. The migration-job stage is where 05's R2 exclusivity requirement is actually enforced — a concurrency-gated pipeline stage, not a database lock, since no single lock mechanism covers both the right scope (session, to span `goose`'s per-file transactions) and the right engine (CockroachDB supports transaction-scope only). 49 50 **Acceptance criteria:** 51 - [ ] A developer or reviewer can act on this pipeline description without re-deriving why each stage exists. 52 - [ ] A lint or test failure blocks merge — enforcement site: required-status-check on the PR, not advisory. 53 - [ ] A CockroachDB-specific SQL incompatibility surfaces at PR time, not deploy time — enforcement site: the dedicated CockroachDB testcontainer job. 54 - [ ] Only one migration run can reach the migrate stage at a time, across concurrent deploys — enforcement site: the pipeline's own concurrency group/mutex on the deploy environment. 55 - [ ] No secret enters a build log or image layer — enforcement site: `gitleaks` stage plus the Dockerfile's minimal `COPY` list (Story 1). 56 - [ ] The final deploy stage is documented (which manifests, which namespace, which credential) but not invoked — enforcement site: stage exists as a named, non-executing step until a separate real-deploy decision is made. 57 58 **Depends on:** 03 — test-strategy outline for `go test ./...` to run against; 05 — `migration-approach.md` (final). 59 60 **Model/effort:** Sonnet, medium. 61 62 **Type:** Story. 63 64 **Labels:** `track-04`, `no-code-yet`. 65 66 ## Story 4 — Local dev-loop: compose profiles for SQLite and Postgres 67 68 **Description:** Build the `docker-compose.yml` in `./local-dev-loop.md`: `sqlite` and `postgres` profiles matching 03's `DB_DRIVER` env var, a mock/local `idp-connector` target, hot-reload (`air`) for `api-service` in dev profiles only, and local-only dev credentials clearly distinguished from the real secrets mechanism (Story 2). 69 70 **Acceptance criteria:** 71 - [ ] A new developer can run either profile from this file alone, without asking what `DB_DRIVER` values are valid. 72 - [ ] The `postgres` profile exercises the shared `postgres`-package code path (Postgres + CockroachDB) — enforcement site: compose service definition wiring `DB_DRIVER=postgres`. 73 - [ ] Dev credentials never appear in `./secrets-delivery.md`'s mechanism or in any real manifest — enforcement site: explicit statement in the file plus separate variable naming (`dev`/`dev`, hardcoded client id/secret). 74 - [ ] Hot-reload has zero effect on the production image build — enforcement site: `air` target scoped to compose dev profiles only, never `./containerization-design.md`'s Dockerfile. 75 76 **Depends on:** 03 — `DB_DRIVER`/config-surface env vars (already stable, per `PLANNING.md`). 77 78 **Model/effort:** Sonnet, low. 79 80 **Type:** Story. 81 82 **Labels:** `track-04`, `no-code-yet`. 83 84 ## Story 5 — Observability: logging, metrics, never-log enforcement, retention-sweep alerting 85 86 **Description:** Implement `./observability.md`: structured `slog` logging with an explicit-field discipline (never a whole request/response struct through a log call), a lint check flagging never-log-list field names, RED metrics with no PII-cardinality labels, ingress-level query-string redaction for search endpoints, RBAC-separated audit-log read access, and 05's S5 retention-sweep requirement — rows-examined/rows-deleted/oldest-surviving-row-age metrics per data class, with an alert when oldest-row-age exceeds retention-window-plus-sweep-interval, since a dead sweep job produces zero rows-deleted either way and gives no other symptom. 87 88 **Acceptance criteria:** 89 - [ ] 02 confirms the never-log list (`handoff-04-secrets.md`'s nine items) is fully covered by a named enforcement site, not just a policy statement. 90 - [ ] No password, token, JWT, client secret, or DSN reaches a log call — enforcement site: explicit-field logging discipline plus the CI lint check. 91 - [ ] Search-endpoint name/phone query fragments never appear in ingress access logs — enforcement site: ingress log-format override / redaction. 92 - [ ] Audit-log read access is a distinct RBAC grant from Secret-read access — enforcement site: separate `Role` bound to an incident-review group. 93 - [ ] A stopped retention-sweep job triggers an alert within one sweep interval of its window being exceeded — enforcement site: the `oldest_surviving_row_age_seconds` alert rule, per data class. 94 95 **Depends on:** 02 — `handoff-04-secrets.md`'s never-log list (final); 05 — `pii-governance.md` S5 retention numbers and data-class names (final). 96 97 **Model/effort:** Sonnet, medium. 98 99 **Type:** Story. 100 101 **Labels:** `track-04`, `security-graded`, `no-code-yet`. 102 103 ## Story 6 — Derived-data deletion / log-retention enforcement (placeholder, pending consistency pass) 104 105 **Description:** Placeholder for the enforcement site of "logs and any derived/cached data must not outlive the data they're derived from" — the interaction the consistency pass is explicitly checking (`PLAN.md` "Retention and deletion": `05/pii-governance.md` S5 numbers and deletion-job contract vs. `04/observability.md` vs. `02/threat-model.md` assumptions). 04 is the presumptive owner of the enforcement site if the pass confirms it's a log-retention/observability-pipeline mechanism (e.g., a log-sink retention policy matching 05's window, not a separate deletion job) rather than a DAO-level or connector-level mechanism. Not filled in beyond this placeholder until the pass reports, per team-lead's instruction — this story exists so the gap is tracked now rather than discovered after transcription. 106 107 **Acceptance criteria:** 108 - [ ] Left open pending the consistency pass's ruling on which track owns the enforcement site. 109 110 **Depends on:** 02 — `threat-model.md` assumptions about derived-data lifetime; 05 — `pii-governance.md` S5 deletion-job contract and retention numbers. 111 112 **Model/effort:** TBD, pending scope. 113 114 **Type:** Story. 115 116 **Labels:** `track-04`, `from-consistency-pass`, `no-code-yet`. === kid defined anywhere in 02? === 02-ai-security-architecture/handoff-03-auth.md:21:- **Key rotation:** expect a `kid` claim; publish old and new public k 02-ai-security-architecture/handoff-04-secrets.md:17:| 2 | JWT signing **private** key (RS256/ES256), per `api-auth-desi 02-ai-security-architecture/handoff-04-secrets.md:44:5. JWT signing private key (#2) and any JWT in full — log `sub`, `a === api-auth-design mentions mask/expand? === 02-ai-security-architecture/handoff-03-auth.md:0 02-ai-security-architecture/api-auth-design.md:0 (root decisions/ does not exist yet)
{
"command": "mkdir -p /Users/warrenthompson/Source/LoginID/decisions && echo ok",
"description": "Create the root decisions directory"
}ok
{
"file_path": "/Users/warrenthompson/Source/LoginID/decisions/cross-track-consistency.md",
"content": "# Cross-track consistency pass — Phase 3 close\n\nRun 2026-09-12 as a fresh Fable session with no accumulated context, against the 12-seam checklist in `PLAN.md` (\"Consistency-pass checklist\") plus the four items handed over by the 05 lead. Only finished deliverables were read; no existing file was modified. Line numbers are from the files as they stood at the start of the pass (the working tree changed underneath the pass — see Mechanical checks). Owning leads: 01 Naomi Voss, 02 Marcus Ilori, 03 Renata Cole, 04 Theo Bergman, 05 Priya Nandakumar; root files Dana Whitfield.\n\n## Executive summary\n\nFourteen sections were checked (twelve seams, the vocabulary sweep, the deletion gap). Classified by the most severe finding in each: **9 CONTRADICTION** (seams 1, 2, 3, 4, 5, 7, 8, 10, 11), **5 GAP** (seams 6, 9, 12, vocabulary sweep, deletion gap), **0 clean AGREE** — though the core test in eight seams passes (endpoint shapes, scope vocabulary, sentinel set, DB_DSN_FILE precedence, issuer/audience literals, R1–R5, retention numbers, §6 enforcement sites), and most contradictions are one-sentence stale references left behind by a later ruling. Fifty individual findings are numbered F1–F50 below.\n\nThe three most consequential: **(1) the vendor token has no consistent home** — 01's onboarding flow holds it across two screens (`ux-notes.md:27`), the assignment's `/auth` returns it to the caller, yet 02 says it is never returned to callers and lives inside one function call (`connector-security.md:19-23`); no document says what our `/auth` returns or how `/identity` obtains vendor authority (F3). **(2) The JWT signing key is mounted into every `api-service` replica** (`containerization-design.md:55,61-62`) while 02 requires it never touch `api-service` and at the same time defines the issuer as \"a mode of `api-service`\" (`handoff-04-secrets.md:8,17`); Secret-`get` RBAC does nothing for a mounted volume (F13). **(3) The final migration layout puts all `CREATE TABLE`s in a `shared/` series** (`migration-approach.md:24`) that the field-level schema cannot express — every table has per-engine types, collations and CHECK syntax (`multi-db-strategy.md:341-368`) — and the document still carries an \"under review in story S1\" item after S1 closed (F28).\n\nAlso material: cursor-only pagination in 02's hand-off versus `Offset int` in 05's accepted contract (F-pag); partial phone match required by 01, exact-only in 05 (F9); no deletion-job or `deletion_log` method in the DAO contract, so the retention mechanism cannot be written against it (F21); the never-log list does not cover the single most likely PII-to-log path — Postgres CHECK/unique-violation `DETAIL` lines carry the failing row (F50); and 02's AI-workflow narrative miscounts its own red/blue tally against the decision record it cites (F36).\n\n**Verdict:** the submission is not internally consistent enough to declare planning done as it stands. It will be after bounded two-party fixes on F3, F13, F28, F-pag, F21 and F50, plus the stale-status sweep in seam 11 — none of which requires reopening a debate, and each of which names one document to change. Re-run seams 1/4, 3, 5, 7 and 9 after those land.\n\n## Seam 1 — `/identity` response and `/auth` request shapes — CONTRADICTION\n\n**F1 (agree).** The shapes are identical everywhere they appear: root `CLAUDE.md` assignment text; `01/api-connector-design-rationale.md:71` (\"Endpoints, methods, and JSON bodies for `/auth` and `/identity` are fixed by the assignment\") and `:73` (the seven PII fields \"match the assignment exactly\"); `02/connector-security.md:7-8`; `03/PLAN.md:23` (\"per the assignment's fixed JSON contracts\"); `05/pii-governance.md:7` lists the same five address subfields; `05/multi-db-strategy.md:115-122` carries them as columns. Nobody drifted on the fixed contract.\n\n**F2 (contradiction — 01 vs 02).** 01's contract summary tells 03 to build `Authenticate(username, password) → token` and `FetchIdentity(token, phone, name) → PII` with \"the calling code (onboarding flow)\" using both (`api-connector-design-rationale.md:40,72`) — the token is returned from one method to the caller and passed into the next. 02 requires the opposite: the token \"is not passed up the call stack beyond the vendor-call function\" and its scope \"runs from the `/auth` response to the immediately following `/identity` call and no further\" (`connector-security.md:20`). 03 receives both as \"build against this\". Owner: Naomi. Smallest fix, one document (`api-connector-design-rationale.md` contract summary): state that the two-method shape is the vendor adapter's internal interface and the exported operation is a single identity lookup that performs the auth-then-fetch inside the adapter, so the token never crosses the adapter boundary.\n\n**F3 (contradiction — 01, 02, assignment).** The documents cannot all be true about who holds the vendor token between our `/auth` and our `/identity`. The assignment says `/auth` \"retrieves an `access_token`\" for its caller. 01 builds on that: \"a short-lived token stands in for the password everywhere past the first screen\" (`ux-notes.md:27`), i.e., held across two user-facing requests; `personas-use-cases.md:33` says the same. 02 says the token is \"fetched fresh from `/auth` for each `/identity` call, held only inside that single request's execution scope\" (`connector-security.md:19`), is \"Not returned to our own API callers\" (`:23`), and the end-user's vendor password is \"Never persisted, never cached, held only for the vendor call\" (`handoff-04-secrets.md:25`). Our `/identity` body is fixed as `{\"phone\",\"name\"}` with no credential field. So on a `/identity` call the connector has neither a cached token, nor the user's password, nor a token from the caller — unless the caller presents one, which `:23` forbids. `connector-security.md` §6 (`:58-60`) discusses \"a vendor `access_token` obtained from one person's `/auth` call\" being used at `/identity`, which presumes exactly the caller-presented flow `:23` rules out. This is the ambiguity Ingrid's S8 finding 2 (`decisions/second-review-security-docs.md:33`) brushed against without resolving. Owner: Marcus (01's UX follows the assignment; 02's restriction is the added constraint). Smallest fix, one document (`connector-security.md` §1): one paragraph stating what our `/auth` returns to its authenticated internal caller (the vendor token pass-through, or an opaque handle) and that our `/identity` accepts it as the vendor credential for that call; rewrite `:23` from \"not returned to our own API callers\" to \"returned only to the authenticated `connector:identity-lookup` caller, never to end users, never persisted server-side\". If 02 instead keeps `:23`, then the assignment's `/auth` has no purpose in this design and 01's two-screen flow must be withdrawn — which is why the fix belongs in 02.\n\n## Seam 2 — Authorization scopes and their semantics — CONTRADICTION\n\n**F4 (agree).** The scope vocabulary is identical in `02/api-auth-design.md:38-40`, `02/handoff-03-auth.md:25-31`, and `02/decisions/search-authz-scoping.md:14`; the closed `reason_code` enum matches (`api-auth-design.md:54` ↔ `handoff-03-auth.md:31`); the `profile:read:any` decomposition-counter fix is present in both `api-auth-design.md:62` and `handoff-03-auth.md:39` with the same wording and the same S8 attribution. 01's product requirements (`personas-use-cases.md:39-54`) are honoured on attribution, pagination, no-bulk-export and volume limiting.\n\n**F5 (contradiction — internal to `handoff-03-auth.md`).** Line 10: \"If `cmd/idp-connector` ever needs its own audience value (it doesn't consume this token scheme today …) … not needed for this submission.\" Line 19: \"`idp-connector`'s own `/auth` and `/identity` handlers need the same JWT middleware pattern, with a **distinct audience** (`aud: idp-connector-service` …) … Two audiences, one mechanism.\" The v2 update added line 19 without removing line 10. Owner: Marcus. Fix: delete the parenthetical sentence at line 10.\n\n**F6 (gap — 01 requirement with no 02 mechanism).** 01 requires \"Masked/partial PII by default on every search result, with full-field detail requiring a distinct, more tightly scoped call\" (`personas-use-cases.md:42`), a deliberate expand \"with its own audit-log entry distinct from the search's own entry\" (`:43`), masking independent of query specificity (`:47`), and `expand: true` that \"should require a more tightly-scoped credential than the base search call\" (`api-connector-design-rationale.md:63`). Neither `api-auth-design.md` nor `handoff-03-auth.md` contains the words \"mask\" or \"expand\" (grep: zero hits in both), and the hand-off says `profile:search` \"is the broadest scope; there is no broader one\" (`handoff-03-auth.md:29`) — so \"tighter than search\" has nowhere to bind. 03 has no rule on response field shape. Owner: Marcus. Smallest fix, one document (`handoff-03-auth.md`): add a response-shape bullet — `profile:search` responses carry masked fields; unmasked detail is obtained only by a follow-up `GET /profiles/{id}` under `profile:read:own`/`read:any`, which is its own audited call. That gives 01's requirement a mechanism using scopes that already exist and turns 01's `expand: bool` into a client-side navigation rather than a server flag; 01 then drops the \"tighter credential\" clause at `:63`.\n\n**F7 (gap — 03).** `handoff-03-auth.md:27,50` requires that a scope failure and an `authorize()` policy denial be \"distinct signals for detection\". `03/decisions/error-semantics.md` maps DAO sentinels and context errors to HTTP (`:21-23`) but says nothing about what an authn or authz failure returns — the checklist's explicit question for this seam. Owner: Renata. Fix: one line in `error-semantics.md`: missing/invalid token → 401; scope failure and policy denial → 403 with an identical client body, distinguished only in the audit log.\n\n**F8 (gap — data with no home).** `handoff-03-auth.md:27` says the `caller_referral` mapping is \"data owned by `../05-data-ops/`\", and `api-auth-design.md:53` says \"`caller_referral` entries are data owned by `../05-data-ops/CLAUDE.md`\". No 05 document mentions `caller_referral`, a caller-role table, or a `reason_code` store (grep across `05-data-ops/`: zero hits); 05's field-level schema has three tables plus `deletion_log`. The same applies to the \"explicit caller-role table\" (`api-auth-design.md:39`). This is an ownership assertion the asserted owner never received. Owner: Marcus (to route as a two-party item with Priya). Smallest fix, one document (`handoff-03-auth.md`): place `caller_referral` and the caller-role table in the authorization server's own datastore — which row 4 of `handoff-04-secrets.md:19` already says holds the hashed `client_secret`s — rather than in 05's schema, and say so.\n\n**F9 (contradiction — 01 vs 05).** 01: \"Search on `name` and `phone`, with partial-match support — a support analyst rarely has a clean, exact value for either\" (`personas-use-cases.md:41`); the UX search screen has a \"partial-match toggle\" (`ux-notes.md:11`). 05: `Phone *string // EXACT match on the E.164-normalized value (not prefix)` (`multi-db-strategy.md:187`) and \"**Exact match only** — … the contract does not offer it\" (`:413`), a deliberate S2/S6 ruling. Owner: Naomi — 05's position is debated, enforced by the index definition, and accepted by 03. Fix: `personas-use-cases.md:41` → name partial, phone exact after E.164 normalization, recorded as an accepted product limitation (an analyst reading a full number aloud is the exact-match case).\n\n**F10 (minor gap — empty query, page size).** 01 requires rejecting a query with no search field (`personas-use-cases.md:52`); 05 says an all-nil query \"is legal and returns every profile\" and defers the policy to 02 (`multi-db-strategy.md:200`); 02 rejects only a query \"that would exceed [the result cap] unconstrained\" (`handoff-03-auth.md:29`), a count-based rule, not a shape-based one. Compatible only if 03's handler rejects an empty search body, which no document says. Separately, the API default/ceiling is 20/50 (`handoff-03-auth.md:37`) and the DAO's is 50/100 (`multi-db-strategy.md:190`); no document names the clamp. Owner: Marcus for the first (one bullet in `handoff-03-auth.md`: \"a search body with no filter is rejected before the DAO\"), Renata for the second (name the clamp in the S3 middleware spec).\n\n## Seam 3 — Config surface and secrets — CONTRADICTION\n\n**F11 (agree).** `DB_DSN_FILE` precedence over `DB_DSN` matches in `PLANNING.md:46`, `handoff-04-secrets.md:16`, `04/secrets-delivery.md:11`, `04/containerization-design.md:52`, and the go-layout diagram (`03/decisions/go-layout-debate.md:64`). The literal issuer `https://auth.loginid-takehome.internal` and audience `loginid-api-service` match in `PLANNING.md:46`, `handoff-03-auth.md:10`, and `go-layout-debate.md:32-33`; 04 correctly treats them as ConfigMap values, not secrets (`containerization-design.md:50`).\n\n**F12 (contradiction — version label).** `handoff-04-secrets.md:3` reads \"Status: **v1** … Awaiting 04's Newcomer (`wesley-okonkwo`) cold read.\" The cold read happened and its fixes are in the file (`04/decisions/newcomer-coldread-boundaries-secrets.md:36-39`); 04 cites \"v2\" in `secrets-delivery.md:3,37`, `observability.md:44`, `containerization-design.md:5`, and `newcomer-coldread-boundaries-secrets.md:43`; `PLAN.md:134` and `02/CLAUDE.md:51` say v1. One document, three labels. Owner: Marcus. Fix: update the Status line to v2 and remove \"Awaiting\".\n\n**F13 (contradiction — signing-key custody).** `handoff-04-secrets.md:17` row 2: \"Used by: Token-issuing authorization-server component only — **not** `api-service`\". Same document, Assumption 2 (`:8`): \"The token-issuing authorization-server function is a mode of `api-service`, not a third binary … RBAC … restricted (via a distinct Kubernetes Role, not a separate ServiceAccount) so that only the issuing code path — not the token-verification code path in the same binary — can `get` the signing-key Secret.\" `handoff-03-auth.md:20` compresses the contradiction into one sentence: \"The private signing key never touches `api-service`; it lives with the token-issuing function, which is a mode of `api-service`.\" 04 then implements the only thing the manifest can do: mounts `api-service-jwt-signing-key` at `/secrets/jwt` into the `api-service` container of a two-replica Deployment (`containerization-design.md:39,55,61-62`) — every replica, verification path included. A Role restricting `get` on a Secret has no effect on a volume mount; the file is readable by the whole process. `secrets-delivery.md:24` concedes the requirement \"in practice means either a second SA for the issuer mode or a sidecar/init-container pattern\", which the manifest does not do. Owner: Marcus for the requirement (the manifest follows it). Smallest fix, one document first (`handoff-04-secrets.md` Assumption 2 and row 2): the issuer runs as a separate Deployment of the same `api-service` image, selected by an environment variable 03 adds to the config surface (the surface allows no flags — `PLANNING.md:46`), with its own ServiceAccount that alone mounts the signing key; the `api-service` Deployment mounts nothing but the DSN. Then Theo removes the mount at `containerization-design.md:55,61-62` and adds the issuer Deployment.\n\n**F14 (contradiction — compose leaks inventory-forbidden values).** The compose sketch sets `DB_DSN: postgres://dev:«REDACTED»@postgres:5432/…` (`local-dev-loop.md:24`) and `IDP_ABC_CLIENT_ID: dev-client` / `IDP_ABC_CLIENT_SECRET: dev-secret` as literal env values (`:38-39`) in a file that would be committed. Row 5 of the inventory says these are \"never in source or committed config\" with \"file mount preferred\" (`handoff-04-secrets.md:20`) and row 1 makes `DB_DSN_FILE` \"Required, not preferred\" (`:16`). The disclaimer at `local-dev-loop.md:50` explains the intent but the inventory grants no development exemption. Owner: Theo. Fix, one document (`local-dev-loop.md`): use `${IDP_ABC_CLIENT_SECRET:?}`-style required variables sourced from an untracked `.env`, and `DB_DSN_FILE` pointing at a bind-mounted dev file — or ask Marcus for an explicit dev-profile exemption row.\n\n**F15 (gap — the connector's own audience has no config, and its caller has no credential).** `connector-security.md:52` and `handoff-03-auth.md:19` require the connector to verify JWTs with `aud: idp-connector-service` and scope `connector:identity-lookup`. The config surface has one `AUTH_JWT_AUDIENCE` in a shared `internal/config` (`PLANNING.md:46`; `go-layout-debate.md:65`); nothing says the connector reads it with a different value or has its own variable. And `api-service` must obtain a `connector:identity-lookup` token to call the connector, which means `api-service` holds a client credential toward the issuer — a secret that is in none of rows 1–9 of the inventory. Owners: Marcus (inventory row) and Renata (config variable). Fix: add row 10 to `handoff-04-secrets.md` (api-service's client id/secret for the connector audience, file-mounted, `api-service` SA) and one variable to `PLANNING.md` Service boundaries.\n\n## Seam 4 — Connector token lifecycle — CONTRADICTION\n\n**F16 (agree).** No-cache-by-default is stated identically in `connector-security.md:19` and `decisions/connector-token-lifecycle-redblue.md:27,34,55`; the attack-tree table (`connector-security.md:79-83`) matches the record; 03's outbound `context.WithTimeout` and bounded backoff (`go-layout-debate.md:19`; `PLANNING.md:49`) are compatible with 02's \"a retried `/identity` call re-fetches via `/auth`\" (`connector-security.md:20`) — backoff governs the spacing, re-fetch governs the token. 05 governs cached `/identity` results as `idp_cache` rows on a 30-day clock (`pii-governance.md:33`), and 02 defers exactly that to 05 (`connector-security.md:39,64`).\n\n**F17 (contradiction — stale references to a cache that no longer exists).** After the S3 ruling, these still describe a cached token: «REDACTED» row 7 says the red/blue is \"(in progress)\" and caching is the \"current direction\"; `:52` says the connector \"may [cache], under the conditions in #6\"; `secrets-delivery.md:16` \"If cached, encrypted with #7's KEK\"; `03/PLAN.md:23` consumes \"token lifecycle: TTL, storage, refresh, never-persist-in-plaintext\"; `03/decisions/context-propagation.md:23` names \"any token-persistence write\" as a detach point; the go-layout diagram lists \"token lifecycle (TTL/refresh/no-plaintext-persist)\" (`go-layout-debate.md:45-49`); and `04/observability.md:37` invents a retention class `vendor_token_cache`. None is load-bearing, but each tells a reader the opposite of the ruling. Owners: Marcus (`handoff-04-secrets.md:22,52`), Renata (`03/PLAN.md:23`, `context-propagation.md:23`), Theo (`observability.md:37`, `secrets-delivery.md:16`). Fix: one-line edits; the primary is `handoff-04-secrets.md` row 7 and \"What 04 does not decide\", which is the hand-off 04 built from. F3 (seam 1) is the substantive lifecycle contradiction and is not repeated here.\n\n## Seam 5 — DAO contract — CONTRADICTION\n\n**F18 (agree — the seam's explicit test passes).** The sentinel set is byte-identical: `03/decisions/error-semantics.md:33` lists `ErrNotFound`, `ErrAlreadyExists`, `ErrDuplicateUsername`, `ErrInvalidMethod`, `ErrInvalidQuery`, `ErrInvalidArgument`, `ErrInvalidCredential`, matching `05/multi-db-strategy.md:282-288` exactly, seven for seven. The translation rule matches (`error-semantics.md:34` ↔ `multi-db-strategy.md:298`). `PLANNING.md:47`'s composite (`Profiles()/Credentials()/Methods()`) matches `multi-db-strategy.md:94-97`. `context-propagation.md` item 1 (multi-table writes in one `Tx`) is satisfied by `CreateProfileWithCredential` (`context-propagation.md:31` ↔ `multi-db-strategy.md:99-101,232-244`).\n\n**F-pag (contradiction — 02→03 vs 05→03 on pagination).** `handoff-03-auth.md:37`: \"**Cursor-based pagination only** — no offset-based paging.\" `api-auth-design.md:59`: \"offset walks turn a capped page size into full-table enumeration.\" `multi-db-strategy.md:191`: `Offset int // 0..10_000`; `decisions/search-query-shape.md:36`: \"Limit/offset … Keyset rejected as designing for an uncommitted dataset size.\" 03 has accepted both hand-offs. 02's stated reason does not actually distinguish the two: a keyset cursor also permits a full page-by-page walk; the enumeration control is the cumulative distinct-record counter (`api-auth-design.md:61`), not the paging scheme. Owner: Marcus. Smallest fix, one document (`handoff-03-auth.md:37`): \"opaque cursor at the API boundary; it may encode the DAO's offset; enumeration resistance rests on the cumulative counter\" — and correct the reason at `api-auth-design.md:59`. The alternative (05 moving to keyset) reopens an accepted contract for no security gain.\n\n**F19 (contradiction — cancellation).** `multi-db-strategy.md:228`: \"`Context` is first on every method and cancellation is honoured.\" `context-propagation.md:23`: \"Credential writes … use `context.WithoutCancel` … for the actual commit\" — inside the DAO, which would mean the DAO does not honour cancellation on those methods. Owner: Renata. Fix: state that any detach happens in the handler before the DAO call (a fresh bounded context passed in), so the contract's guarantee stands unmodified; drop \"token-persistence write\" (F17).\n\n**F20 (gap — the hand-off's first section contradicts its second).** `multi-db-strategy.md:9-13` still says `Update` is \"implemented as a portable upsert\", phone is \"exact/prefix\", pagination is \"limit/offset\" with no ceiling, and there is one sentinel `ErrNotFound`. All four are retracted lower down (`:214-222`, `:187`, `:190-191`, `:282-288`), and `:214` says \"this changes the earlier prose\" — but a receiver reading top-down meets three retracted statements before the retraction. This fails the hand-off standard (`planning-approach.md:39`) in a document that otherwise meets it unusually well. Owner: Priya. Fix: one banner line at `:5` naming the Go-shaped contract as authoritative, or delete `:9-13`.\n\n**F21 (gap — the deletion mechanism has no DAO method and no implementer).** `pii-governance.md:50-51` specifies a bulk sweep and a subject-deletion request sharing \"the same code path\", each writing a `deletion_log` row; `multi-db-strategy.md:392-406` defines the table. The Go contract has no `DeletionLogRepository`, no sweep or delete-expired method, and `Profiles().Delete(ctx, id)` writes no log row (`:168,:257`); `:228` forbids 03 from opening a transaction to combine the two. 03's stories S1–S6 (`03/PLAN.md:20-25`) contain no sweep job; `pii-governance.md:77` gives 04 \"where … deletion jobs actually run\", not the code. So the retention policy's mechanism cannot be implemented against the contract and no track owns writing it. Owner: Priya (contract), then Renata (a story). Fix, one document (`multi-db-strategy.md`): add to the composite a `DeleteExpired(ctx, class, olderThan)` returning rows examined, rows deleted and oldest-surviving age (the three metrics `pii-governance.md:62` demands) and a `DeleteProfile(ctx, id, reason, externalRef)` — both writing `deletion_log` in the same transaction.\n\n**F22 (gap — who writes `idp_cache` rows).** `go-layout-debate.md:78-80`: \"`internal/dao` is imported ONLY by `cmd/api-service` — `idp-connector` has no DB access, by design: it never touches `user_profile`/`user_credential` storage.\" `multi-db-strategy.md:222`: \"Resolving an `/identity` payload to an existing profile row is **the connector's problem**, not the DAO's … The connector decides identity and then calls `Create` or `Update`.\" `pii-governance.md:52` describes the re-hydration upsert without naming the writer. Owner: Priya (wording). Fix: `:222` → \"the connector's caller (`api-service`) decides identity\"; the diagram stands.\n\n**F23 (minor).** `PLANNING.md:47` says \"the three sub-interfaces' method signatures finalize once 05's contract lands\" and omits `CreateProfileWithCredential` and `Close()`; the contract has landed. Owner: Renata.\n\n## Seam 6 — Multi-DB behavior — GAP\n\n**F24 (agree).** The engine-aware retry seam (`multi-db-strategy.md:319-331`; `research-cockroachdb-postgres-semantics.md:11`), the `LIKE` case-folding fix (`multi-db-strategy.md:37,208`; `decisions/multi-db-abstraction.md:27`), and 04's CI matrix — SQLite in-process plus separate Postgres and CockroachDB testcontainer jobs (`04/ci-pipeline.md:9`) — are mutually consistent. CI does run against every backend including CockroachDB.\n\n**F25 (gap — the conformance suite exists as a requirement but not as anyone's deliverable).** 05 makes the per-driver ruling \"conditional on\" a single suite \"written once against the interface, executed against every backend\" (`multi-db-strategy.md:436-442`; `decisions/multi-db-abstraction.md:21,32`). 03's test story is \"Table-driven unit tests for DAO (mocked driver)\" (`03/PLAN.md:24`) with hand-written fakes (`decisions/test-double-strategy.md:19`); `ci-pipeline.md:9` runs \"unit tests against 03's `internal/dao` per-backend implementations\". Only `error-semantics.md:36` acknowledges §7, and only for sentinel parity. The backends are wired in CI; the suite that would run on them is nobody's story. Owner: Renata. Fix, one document (`03/PLAN.md` S5): add the conformance suite against the `Repository` interface, run against all three drivers, as a named deliverable.\n\n**F26 (contradiction — reasoning, 04 internal).** `local-dev-loop.md:46`: a CockroachDB profile \"would test the same code again, not new code.\" The contract's `withRetry` \"retries only when `engine == cockroachdb`\" (`multi-db-strategy.md:331`) — the CockroachDB path is different code, which `ci-pipeline.md:9` itself argues (\"wire-compatible isn't schema-compatible\"). Owner: Theo. Fix: reword to \"omitted locally for loop speed; the CockroachDB path is exercised in CI\".\n\n**F27 (minor — false claim).** `ci-pipeline.md:9` says `-race` catches \"a migration-runner exclusivity requirement (05's R2)\". The Go race detector is intra-process; R2 concerns two runner processes. Owner: Theo. Fix: delete the clause.\n\n## Seam 7 — Migrations — CONTRADICTION\n\n**F28 (contradiction — 05 internal, propagated to 04).** `migration-approach.md:24`, marked \"final\" (`:7`), says `shared/` \"carries `auth_method`, `user_profile`, `user_credential`, `deletion_log` … and the `auth_method` seed row\", with `postgres/` holding exactly one file. The field-level schema it must implement gives every table per-engine DDL: `UUID DEFAULT gen_random_uuid()` vs `TEXT` (`multi-db-strategy.md:341`), `TEXT COLLATE \"C\"` vs `TEXT COLLATE BINARY` (`:342`), a regex `CHECK` vs a `GLOB` `CHECK` (`:343`), `TIMESTAMPTZ` vs `TEXT` (`:350`), `BYTEA` vs `BLOB` (`:363`), `BOOLEAN` vs `INTEGER` (`:388`). A single shared `CREATE TABLE` series cannot express this; SQLite's type affinity would swallow the type names but not the collation or CHECK syntax. The document's own fallback — \"per-backend `CREATE TABLE` files and `shared/` reduced to seed data\" (`:28`) — is what §5 actually requires, and `:28` still says the question \"is under review in story S1\" although S1 is closed. The checklist forbids anything provisional remaining; this is provisional and wrong in its default. Owner: Priya. Fix, one document (`migration-approach.md` §2): `shared/` = seed data only; `postgres/` and `sqlite/` hold the DDL; the two-invocation mechanism, R1–R5 and 04's design are unchanged (as `:28` already promises). R3's example (\"`shared/` current but the `pg_trgm` index unapplied\", `:42`) still holds.\n\n**F29 (agree — the 04 side).** R1 (job before rollout) ↔ `containerization-design.md:78`; R2 (orchestration exclusivity, with the corrected reasoning) ↔ `containerization-design.md:80` and `ci-pipeline.md:18`, both restating the \"right scope or right engine, never both\" argument verbatim; R3 (both version tables) ↔ `containerization-design.md:82`; R4 (two credentials, `CREATE EXTENSION` on Postgres only) ↔ `secrets-delivery.md:20`; R5 ↔ `containerization-design.md:78` (`backoffLimit`) and `ci-pipeline.md:23`. The single-runner requirement is platform-enforced everywhere it appears.\n\n**F30 (open item correctly presented).** Whether goose's default version table has a unique `version_id` is stated as \"unverified here — I am not asserting it either way\" (`migration-approach.md:40`), with the actionable backstop independent of the answer; `decisions/migration-approach-objections.md:25-27` records why it was left open; `containerization-design.md:80` repeats \"regardless of whether goose's default schema already has one\". Nowhere is it asserted as fact. Confirmed and left open, as instructed.\n\n**F31 (minor — stale research text).** `research-cockroachdb-postgres-semantics.md:40` still mandates \"by SQLSTATE code only — never by matching … constraint-name substrings\" and `:55` says \"`Update()` in our contract *is* a portable upsert\"; both were superseded (`multi-db-strategy.md:298,214`) with no correction marker in the research file, although `05/CLAUDE.md` says corrections are \"visible in place\". Owner: Priya.\n\n## Seam 8 — `auth_method` and credential storage — CONTRADICTION\n\n**F32 (agree).** `auth_method` as a lookup table with `requires_secret` and `is_active` is consistent across `threat-model.md:8`, `multi-db-strategy.md:43,141-147,382-390`, `api-connector-design-rationale.md:58`, and `migration-approach.md:54-58`; 02's method-aware hashing decision (`threat-model.md:14-17`) targets the reserved columns 05 kept unchanged (`05/PLAN.md:142`); passkey rows carry `secret NULL` in both (`threat-model.md:17` ↔ `multi-db-strategy.md:157,372-376`).\n\n**F33 (contradiction — 01 claims more than the schema supports).** `industry-framing.md:36`: \"`webauthn` or `passkey` tomorrow, **without changing the table shape**.\" `api-connector-design-rationale.md:13`: \"a future `\"webauthn\"` row would have a public key and credential ID instead, not a password field left null by convention.\" The schema has no public-key, credential-id or counter column; `secret` is \"Salted hash only\" (`multi-db-strategy.md:363`); and 02 says public-key material \"does not belong in `secret`/`hash_algo`/`hash_cost` at all if/when a passkey method is actually added\" (`threat-model.md:17`), with counter and credential-id integrity as separate requirements (`:32`). A passkey row today is exactly \"a password field left null by convention\" (`secret_state = 'none'`), and adding passkeys changes the table shape. Owner: Naomi. Fix, one document (`industry-framing.md:36` and the mirror at `api-connector-design-rationale.md:13`): \"without changing the `method` discriminator — method-specific columns or a side table arrive by migration\".\n\n**F34 (minor contradiction — column type).** `threat-model.md:12` describes `secret` as \"nullable TEXT\" and `:15` stores the PHC string \"inside the `secret` column's own text\"; the contract types it `BYTEA`/`BLOB`, Go `[]byte` (`multi-db-strategy.md:133,363`). Storable, but the description is wrong and `secret_state` (`:134`) is never mentioned by 02. Owner: Marcus. Fix: `threat-model.md:12` → \"bytes holding the PHC-encoded string\". Latent, no fix now: the table-level CHECK (`:372-376`) requires `hash_algo`/`hash_cost` whenever a secret is set, so an encrypted-not-hashed TOTP secret (`threat-model.md:32`) is unrepresentable — consistent with 02's own statement that TOTP is uncommitted (`handoff-04-secrets.md:22`), but worth one sentence in either document.\n\n## Seam 9 — Retention and deletion — GAP\n\n**F35 (agree).** Windows 24 months / 30 days / 7 days and their clocks (`pii-governance.md:32-34`) are restated correctly nowhere else and contradicted nowhere; the alert rule \"oldest surviving row age > window + one sweep interval\" is identical in `pii-governance.md:64` and `observability.md:35,37`; the three sweep metrics match (`pii-governance.md:62` ↔ `observability.md:35`); `deletion_log` is PII-free in both (`pii-governance.md:58` ↔ `multi-db-strategy.md:392-406,433`); `threat-model.md:3,57` correctly defers retention to 05.\n\n**F36 (contradiction — class names).** `observability.md:37` labels the sweep metrics by \"retention class, e.g. `vendor_token_cache`, `idp_identity_cache`\". 05's classes are `source='direct'`, `source='idp_cache'`, and orphaned `idp_cache` (`pii-governance.md:32-34`); `vendor_token_cache` is not a retention class and describes a cache the S3 ruling abolished (F17). An alert keyed to a nonexistent class fires never. Owner: Theo. Fix: use 05's `source` values plus an `orphan` label.\n\n**F37 (gap — log retention is disclaimed by both candidate owners).** `observability.md:41`: \"no log-retention-window design (that's 05's PII-retention territory for anything log-adjacent to PII …)\". `pii-governance.md:72`: derived data \"sits across `../02-ai-security-architecture/` … and `../04-infra-devops/` (log retention and where those logs physically live)\". The checklist's question — logs must not outlive the data — is answered by neither. Treated in full under \"Deletion gap\" below.\n\n## Seam 10 — Narrative claims — CONTRADICTION\n\n**F38 (agree).** `ai-workflow-narrative.md` reports the S5 ruling faithfully including the losing position (`:44` ↔ `decisions/ai-workflow-claim-debate.md:28-31`), the S8 reassignment (`:37` ↔ `decisions/second-review-security-docs.md:7`), the never-contacted operator agent (`:48` ↔ `threat-model.md:21`, `LOG.md:33`), and explicitly disclaims a general multi-agent-beats-solo claim (`:34,44`). `LOG.md` entries name the decision files that exist and describe them accurately. Null results are reported — but miscounted, below.\n\n**F39 (contradiction — the S3 tally).** `ai-workflow-narrative.md:36`: Tomasz's rebuttal \"conceded two of the remaining three leaves cleanly (no new finding) and tightened the third … **Tally: 2 hits (deleted leaves), 1 tightening, 2 null (clean concessions).**\" The record it cites says leaf 1 was \"conceded, conditional\" with a new \"connection-reuse variant Helena and Felix didn't address\", which the ruling then \"closed\" with a new required control (`decisions/connector-token-lifecycle-redblue.md:46,54`); `connector-security.md:85` agrees (\"surfaced one unaddressed variant … and one boundary\"). Only leaf 2 was a clean concession. Two sibling documents count it that way: \"2 hits, 1 null\" (`ai-workflow-claim-debate.md:20,29`) and \"one of three top-ranked leaves (leaf 2 in S3) was conceded clean\" (`second-review-security-docs.md:43`). Owner: Marcus. Fix: `:36` → 2 leaves deleted, 1 new variant closed from the rebuttal, 1 tightening, 1 null. The error undercounts the pattern's own evidence.\n\n**F40 (contradiction — the S8 count).** `second-review-security-docs.md:43` and `ai-workflow-narrative.md:39` say \"4 + 4 + 2 = 10\". The document's own threat-model section lists five numbered items — \"Four citation/precision fixes, one completeness note\" (`:11-17`) — so the sum is 11, or \"9 defects plus 1 completeness note\". Owner: Marcus.\n\n**F41 (contradiction — internal).** `ai-workflow-narrative.md:40`: \"two real gaps … **1 hit.**\" `ai-workflow-claim-debate.md:12` counts the same event as \"two real gaps\". Owner: Marcus. Fix: \"2 hits\" or \"1 review, 2 findings\".\n\n**F42 (presentation defect).** `second-review-security-docs.md:41` contains a stray drafting artifact in a submission-visible file: \"(4 in threat-model, 4 in api-auth-design — wait, tally below)\". Owner: Marcus. Fix: delete the parenthetical.\n\n## Seam 11 — Root files — CONTRADICTION\n\n**F43 (agree — mechanical).** `grep -c profile-gen:start` totals exactly 6 (one each in root `CLAUDE.md` and the five track `CLAUDE.md`s); the code-artifact `find` returns nothing; `CASTING.md` §8 has exactly 18 hire rows, all `Portrait = done`; `.claude/agents/` holds 18 definitions, every one with a `model:` line.\n\n**F44 (contradiction — stale Status sections).** Root `CLAUDE.md:69`: \"Phase 0 scaffolding done, Phase 1 (leads plan in plan mode and propose hires) next\" — reality per `LOG.md:61` and `PLANNING.md:9-15` is Phase 3 complete. `03/CLAUDE.md:36`: \"No implementation has started. This track's CLAUDE.md is scaffolding only\" — 03 has four decision records, an accepted contract and filled service boundaries. `04/CLAUDE.md:37`: \"No infra work has started. Do not begin until …\" — 04 has five drafted deliverables. 01, 02 and 05 `CLAUDE.md` Status sections are current. Owners: Dana (root), Renata, Theo. Fix: turn-boundary edits of one paragraph each.\n\n**F45 (contradiction — board vocabulary).** `PLAN.md:155` requires that at Phase 3 close \"`PLANNING.md` rows all read 'plan approved'\". Only 01 (`PLANNING.md:11`) and 05 (`:15`) do; 02 (`:12`), 03 (`:13`) and 04 (`:14`) carry free text outside the status vocabulary at `:5`. The Dana row (`:9`) says \"02 on S8–S10\" while the 02 row says all Stories but S11 are done. Owners: Marcus, Renata, Theo, Dana.\n\n**F46 (contradiction — registry vs record).** `CASTING.md:142` still lists Helena's role as \"second review of threat-model + api-auth-design (S8)\"; the S8 record reassigned `api-auth-design.md` to Tomasz (`second-review-security-docs.md:7`), whose row (`:141`) omits S8. Owner: Marcus.\n\n**F47 (contradiction — rename not propagated).** `LOG.md:41` records \"Nolan Reyes — renamed from Callum Reyes to clear the first-name collision\"; `03/PLAN.md:58,66` still name \"Callum\" as the three-hats pessimist and in the devil's-advocate rotation, while the same file's roster (`:38`) names Nolan. Callum Ferreira is a 04 hire. Owner: Renata. Also stale in `03/PLAN.md`: the package layout (`:97-105`) lacks `internal/app` adopted at `go-layout-debate.md:17`, and the config surface (`:107`) lacks `DB_DSN_FILE`; acceptable only if the file is labelled as the Phase 1 snapshot.\n\n**F48 (footer check).** Per `CASTING.md:88`, every decision artifact ends with a footer recording model per seat and hire turns. 11 of 14 comply: all 02 (4), 03 (4), 04 (1) and 05 (4) files — 05's use an italic \"Pattern footer\" form that carries both facts. The three 01 files lack any footer: `decisions/claims-audit.md` (ends at `:26` with \"Outcome\"), `decisions/password-baseline-debate.md` (ends at `:43` with the Ruling), `decisions/rationale-cold-read.md` (ends at `:34`). Owner: Naomi. Fix: append the standard footer to each.\n\n## Seam 12 — Cross-cutting: enforcement sites — GAP\n\n**F49 (agree — the 05 §6 check).** All twelve items in `multi-db-strategy.md:423-434` carry an *Enforcement:* clause naming a DDL constraint, index, normalization step, package boundary, or conformance test; items 10 and 12 name DAO-layer validation and are flagged as the weakest sites rather than dressed up. The rule that \"caught its own author\" (item 10, `:432`; `decisions/handoff-legibility-05-03.md:33`) is now satisfied. Verified item by item.\n\n**F50 (gap — never-log item 3 has no site for the likeliest path).** `handoff-04-secrets.md:42` forbids PII field values in any log; `observability.md:25` covers items 1, 2, 5, 6, 7 with the explicit-field discipline and a lint on variable names (`password`, `token`, `secret`, `dsn`, `client_secret` — `:10`), none of which is a PII field name; item 3 is covered only by \"the same discipline … applied to the connector specifically\" (`:27`). Meanwhile `error-semantics.md:21` makes wrapped errors \"for logs only\", and `observability.md:11` sanitizes driver errors only \"where the error type is known to carry sensitive detail (a DB driver error can echo the DSN …)\". A Postgres CHECK violation on a `user_profile` write retu
… truncatedFile created successfully at: /Users/warrenthompson/Source/LoginID/decisions/cross-track-consistency.md (file state is current in your context — no need to Read it back)
{
"query": "select:SendMessage",
"max_results": 1
}[tool reference: ]
{
"to": "team-lead",
"summary": "Consistency pass complete — executive summary",
"message": "Consistency pass complete: report at decisions/cross-track-consistency.md — planning is NOT yet consistent enough to declare done; six bounded fixes get it there.\n\nCounts: 14 sections checked (12 seams + vocabulary sweep + deletion gap). By most-severe finding: 9 CONTRADICTION (seams 1, 2, 3, 4, 5, 7, 8, 10, 11), 5 GAP (seams 6, 9, 12, vocabulary, deletion), 0 clean AGREE — though the core test passes in eight seams (endpoint shapes, scope vocabulary, sentinel set byte-identical, DB_DSN_FILE precedence, issuer/audience literals, R1–R5, retention numbers, all twelve §6 enforcement sites). 52 numbered findings, each with file:line quotes, owner, and a one-document fix.\n\nThree most consequential:\n1. The vendor token has no consistent home (F3, owner Marcus). 01's onboarding flow holds it across two screens (ux-notes.md:27) and the assignment's /auth returns it to the caller; 02 says it is never returned to callers and lives inside one function call (connector-security.md:19-23). Nobody states what our /auth returns or how /identity obtains vendor authority.\n2. JWT signing key mounted into every api-service replica (F13, owner Marcus then Theo). handoff-04-secrets.md:8 vs :17 contradict each other (\"mode of api-service\" vs \"not api-service\"); containerization-design.md:55 mounts it; Secret-get RBAC is irrelevant to a volume mount.\n3. migration-approach.md:24 puts all CREATE TABLEs in shared/, but the field-level schema has per-engine types, collations and CHECK syntax on every table (F28, owner Priya); a provisional \"under review in S1\" item remains in a \"final\" doc.\n\nAlso material: cursor-only pagination (02) vs Offset int (05); partial phone (01) vs exact (05); no deletion-job/deletion_log method in the DAO contract; never-log list misses Postgres error DETAIL rows; 02's narrative miscounts its own red/blue tally.\n\nMechanical: markers 6 ✓; no code ✓; 18 registry rows all done ✓; footers 11/14 — 01's three decisions lack them. Root CLAUDE.md, 03 and 04 CLAUDE.md Status sections are stale; PLANNING rows 02/03/04 don't read \"plan approved\". Working tree moved under the pass (Phase 4 backlogs appeared); 04's backlog Story 6 is answered in the deletion-gap section. Re-run seams 1/4, 3, 5, 7, 9 after fixes.",
"type": "message",
"recipient": "team-lead",
"content": "Consistency pass complete: report at decisions/cr…"
}{"success":true,"message":"Message sent to team-lead's inbox","msg_id":"1ad73e17-a1b4-46e1-a6d0-c722c9a4f5e5","routing":{"sender":"consistency-pass","senderColor":"green","target":"@team-lead","summary":"Consistency pass complete — executive summary","content":"Consistency pass complete: report at decisions/cr…"}}{
"command": "git status --short && git log --oneline -3 && echo \"=== markers ===\" && grep -c \"profile-gen:start\" CLAUDE.md */CLAUDE.md && echo \"=== code ===\" && find . -type f \\( -name '*.go' -o -name '*.sql' -o -name 'go.mod' -o -name 'Dockerfile' \\) -not -path './.git/*'; echo \"(end)\"; echo \"=== rows ===\" && awk '/^## 8\\./,0' CASTING.md | grep -cE '^\\| [a-z-]+ \\| [A-Z]' && awk '/^## 8\\./,0' CASTING.md | grep -E '^\\| [a-z-]+ \\| [A-Z]' | grep -vc '| done |'; echo \"=== footers ===\" && for f in */decisions/*.md; do tail -n 3 \"$f\" | grep -qiE \"model|turns\" && echo \"ok $f\" || echo \"MISSING $f\"; done; echo \"=== helena/tomasz rows ===\"; grep -n \"^| helena-marsh\\|^| tomasz-wrede\" CASTING.md | cut -c1-260; echo \"=== Callum in 03 ===\"; grep -rn \"Callum\" 03-engineering-delivery/ || echo none; echo \"=== version labels ===\"; grep -rn 'Status: \\*\\*v' 02-ai-security-architecture/handoff-0*.md; grep -rn 'handoff-04-secrets.md` v[0-9]\\|handoff-04-secrets.md v[0-9]' --include='*.md' . | grep -v '^./.git' | cut -c1-140; echo \"=== vendor_token_cache ===\"; grep -rn \"vendor_token_cache\\|idp_identity_cache\" --include='*.md' . | grep -v '^./.git' | grep -v cross-track || echo none; echo \"=== wait artifact ===\"; grep -rn \" — wait, \" --include='*.md' . | grep -v '^./.git' | grep -v cross-track || echo none; echo \"=== new files ===\"; ls 01-product-industry-research-design/decisions/ 03-engineering-delivery/decisions/ 05-data-ops/decisions/",
"description": "Mechanical checks and quick greps for previously-cited stale items"
} M 01-product-industry-research-design/api-connector-design-rationale.md
M 01-product-industry-research-design/backlog.md
M 01-product-industry-research-design/decisions/claims-audit.md
M 01-product-industry-research-design/decisions/password-baseline-debate.md
M 01-product-industry-research-design/decisions/rationale-cold-read.md
M 01-product-industry-research-design/industry-framing.md
M 01-product-industry-research-design/personas-use-cases.md
M 01-product-industry-research-design/ux-notes.md
M 02-ai-security-architecture/CLAUDE.md
M 02-ai-security-architecture/ai-workflow-narrative.md
M 02-ai-security-architecture/api-auth-design.md
M 02-ai-security-architecture/backlog.md
M 02-ai-security-architecture/connector-security.md
M 02-ai-security-architecture/decisions/second-review-security-docs.md
M 02-ai-security-architecture/handoff-03-auth.md
M 02-ai-security-architecture/handoff-04-secrets.md
M 02-ai-security-architecture/threat-model.md
M 03-engineering-delivery/CLAUDE.md
M 03-engineering-delivery/PLAN.md
M 03-engineering-delivery/backlog.md
M 03-engineering-delivery/decisions/context-propagation.md
M 03-engineering-delivery/decisions/error-semantics.md
M 04-infra-devops/CLAUDE.md
M 04-infra-devops/backlog.md
M 04-infra-devops/ci-pipeline.md
M 04-infra-devops/containerization-design.md
M 04-infra-devops/local-dev-loop.md
M 04-infra-devops/observability.md
M 04-infra-devops/secrets-delivery.md
M 05-data-ops/CLAUDE.md
M 05-data-ops/backlog.md
M 05-data-ops/migration-approach.md
M 05-data-ops/multi-db-strategy.md
M 05-data-ops/pii-governance.md
M CASTING.md
M LOG.md
M PLANNING.md
?? 01-product-industry-research-design/decisions/f9-phone-search-ruling.md
3459a6e Phase 3/4: consistency-pass report, five backlogs, Epics and first transcriptions
d05876f Phase 3 complete: all five tracks close planning; consistency pass next
71558d2 Phase 3: debate artifacts, hand-offs, and three tracks' planning outputs
=== markers ===
CLAUDE.md:1
02-ai-security-architecture/CLAUDE.md:1
03-engineering-delivery/CLAUDE.md:1
04-infra-devops/CLAUDE.md:1
01-product-industry-research-design/CLAUDE.md:1
05-data-ops/CLAUDE.md:1
=== code ===
(end)
=== rows ===
18
0
=== footers ===
ok 01-product-industry-research-design/decisions/claims-audit.md
ok 01-product-industry-research-design/decisions/f9-phone-search-ruling.md
ok 01-product-industry-research-design/decisions/password-baseline-debate.md
ok 01-product-industry-research-design/decisions/rationale-cold-read.md
ok 02-ai-security-architecture/decisions/ai-workflow-claim-debate.md
ok 02-ai-security-architecture/decisions/connector-token-lifecycle-redblue.md
ok 02-ai-security-architecture/decisions/search-authz-scoping.md
ok 02-ai-security-architecture/decisions/second-review-security-docs.md
ok 03-engineering-delivery/decisions/context-propagation.md
ok 03-engineering-delivery/decisions/error-semantics.md
ok 03-engineering-delivery/decisions/go-layout-debate.md
ok 03-engineering-delivery/decisions/test-double-strategy.md
ok 04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md
ok 05-data-ops/decisions/handoff-legibility-05-03.md
ok 05-data-ops/decisions/migration-approach-objections.md
ok 05-data-ops/decisions/multi-db-abstraction.md
ok 05-data-ops/decisions/search-query-shape.md
=== helena/tomasz rows ===
141:| tomasz-wrede | Tomasz Wrede | 02 | Attack-tree author | Adversary | playful, bold | sonnet | Red/blue: red seat + rebuttal on connector token lifecycle (S3); optional pre-read on authz scoping (S4); second review of api-auth-design.md (S8, reassigned fro
142:| helena-marsh | Helena Marsh | 02 | Controls author / standards reviewer | Principled Architect | serious, cautious | sonnet | Red/blue: blue seat (S3); adversarial pair: Proposer on authz scoping (S4); second review of threat-model.md only (S8 — reassign
=== Callum in 03 ===
03-engineering-delivery/PLAN.md:70: *(Consistency-pass finding F47: this section named "Callum" as the pessimist/rotation-2 hire
03-engineering-delivery/PLAN.md:71: through Phase 1 while the actual roster in §2 above was already "Nolan Reyes" — Callum
03-engineering-delivery/PLAN.md:72: Reyes was renamed to clear a first-name collision with 04's Callum Ferreira before hiring;
=== version labels ===
02-ai-security-architecture/handoff-03-auth.md:3:Status: **v3** — updated after the cross-track consistency pass (`../decisions/cross-track-consistency.md`) resolved six findings owned by this track that touched this hand-off (F3, F5, F6, F8, F10, F-pag, F52); v2's S8-driven fixes are unchanged and folded in below as before. Receiver: Renata Cole, `../03-engineering-delivery/`. Written so 03 should be able to scaffold and build the middleware without re-deriving the reasoning in `api-auth-design.md`, `connector-security.md`, or the S3/S4/S8 decision records. Full reasoning, if wanted: `api-auth-design.md`, `decisions/search-authz-scoping.md`, `decisions/connector-token-lifecycle-redblue.md`.
02-ai-security-architecture/handoff-04-secrets.md:3:Status: **v2** — v1's S7 objection turn and 04's Newcomer cold read are both resolved and folded in below; this revision additionally resolves four findings from the cross-track consistency pass (F12 this version label, F13 the signing-key custody contradiction, F15 a missing credential row, F50 two never-log gaps). Receiver: Theo Bergman, `../04-infra-devops/`. Written to the project's hand-off standard: 04 should be able to act on this without re-deriving the reasoning in `threat-model.md`, `api-auth-design.md`, or `connector-security.md`.
LOG.md:71:**Pass fixes — 02 closed; all 52 findings addressed.** Marcus fixed 16 in place without reopening a debate. The design answers: **
PLAN.md:134:3. **Config surface and secrets** — `PLANNING.md` Service boundaries vs. `02/handoff-04-secrets.md` v1 vs. `04/secrets-delivery.
02-ai-security-architecture/backlog.md:270:**Depends on:** `handoff-04-secrets.md` v2 (this track); 03's `APP_MODE` config surface addition;
04-infra-devops/observability.md:50:*AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2's neve
04-infra-devops/secrets-delivery.md:3:Design-only. This document implements the delivery mechanism for what `../02-ai-security-architecture/
04-infra-devops/secrets-delivery.md:37:*AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2. Br
04-infra-devops/backlog.md:33:- [ ] 02 (the hand-off's author) confirms this implementation matches `handoff-04-secrets.md` v2's inventory w
02-ai-security-architecture/CLAUDE.md:51:Phase 2 (execution): all four hires spawned and live as named teammates in this session. Completed:
04-infra-devops/containerization-design.md:5:Inputs this depends on: `../PLANNING.md` Service boundaries (03, stable), `../02-ai-security-ar
04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md:8:2. `../02-ai-security-architecture/handoff-04-secrets.md` v1 (owned by 0
04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md:43:04 proceeds on `handoff-04-secrets.md` v2, still gated on 05's migratio
=== vendor_token_cache ===
04-infra-devops/observability.md:37:Mechanism: the sweep job (whatever invokes it — a CronJob against the lab cluster, per 05's mechanism-agnostic requirement) emits these as Prometheus metrics with a `class` label matching 05's actual retention classes — `source="direct"`, `source="idp_cache"`, and `orphan` for an orphaned `idp_cache` row (`../05-data-ops/pii-governance.md`), never a label carrying the PII itself, same discipline as the RED-metrics rule above. (An earlier draft of this section invented `vendor_token_cache`/`idp_identity_cache` as class names — those don't correspond to anything 05 tracks and described a connector token cache the S3 ruling never adopted; corrected here.) The alert rule is `oldest_surviving_row_age_seconds{class="X"} > (retention_window_seconds + sweep_interval_seconds)`, evaluated per class.
=== wait artifact ===
none
=== new files ===
01-product-industry-research-design/decisions/:
claims-audit.md
f9-phone-search-ruling.md
password-baseline-debate.md
rationale-cold-read.md
03-engineering-delivery/decisions/:
context-propagation.md
error-semantics.md
go-layout-debate.md
test-double-strategy.md
05-data-ops/decisions/:
handoff-legibility-05-03.md
migration-approach-objections.md
multi-db-abstraction.md
search-query-shape.md{
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/connector-security.md"
}1 # IDP Connector Security Design — Question 3
2
3 Owner: Marcus Ilori (`02-ai-security-architecture`). Covers the third-party identity-provider connector (generic vendors "ABC"/"XYZ") exposing `/auth` and `/identity`, per the assignment. Implementation target: `../03-engineering-delivery/CLAUDE.md`.
4
5 ## Contract recap (from the assignment, verbatim in `../CLAUDE.md`)
6
7 - `POST /auth` — body `{"username": "<string>", "password": "<string>"}` → returns an `access_token`.
8 - `POST /identity` — body `{"phone": "<string>", "name": "<string>"}` → returns PII (name, phone, address fields).
9
10 Read literally, this connector is a thin proxy in front of ABC/XYZ: it takes vendor credentials, gets a vendor token, and uses that token to pull PII from the vendor. Every security question here is "what does this service do with a vendor secret and vendor PII while it's in our custody," since we don't control the vendor's own security posture.
11
12 ## Threat surface specific to this connector
13
14 This extends the third-party-token row in `threat-model.md`; this document is the concrete design against those threats.
15
16 ### 1. What happens to the vendor `access_token` once retrieved
17
18 - **Never logged.** The `/auth` request body contains a vendor password; the response contains a bearer token. A naive "log the request and response for debugging" habit puts both in the same log line — this is called out explicitly because it's the single most common way a connector like this leaks a credential in practice, not a hypothetical.
19 - **No persistent or cached token, by default.** Revised by the S3 red/blue run (`decisions/connector-token-lifecycle-redblue.md`): the token is fetched fresh from `/auth` for each `/identity` call, held only inside that single request's execution scope, and discarded when the call returns — not written to any cache or datastore as a matter of course. Caching is an explicitly-justified exception requiring its own threat re-review if latency or vendor rate limits ever force the question; if that exception is ever opened, it must satisfy: (a) cache key derived from an immutable, vendor-issued identifier (our tenant id + vendor id + the vendor's own opaque subject id) — never from request-supplied `name`/`phone`, which are attacker-influenced; (b) TTL no longer than the vendor's own token expiry, encrypted at rest (envelope encryption via KMS, mechanics owned by `../04-infra-devops/CLAUDE.md`); (c) if the KMS/envelope key is unreachable at write or read time, fail closed to "no cache, fetch live" for that call — never to an unencrypted write and never to a hard connector failure.
20 - **Fetch-use-zeroize boundary, stated explicitly so it survives implementation:** the token variable's scope runs from the `/auth` response to the immediately following `/identity` call and no further; it is not passed up the call stack beyond the vendor-call function, and it is not reused across a retry. **A retried `/identity` call re-fetches via `/auth`; it never reuses a token held from a prior attempt** — otherwise the "one request" exposure window `§4`'s retry/backoff allowance quietly becomes "however long the retry policy runs." Go gives no guaranteed memory zeroing without unsafe/cgo, so a crash dump taken mid-call could still capture the token in the vendor-call function's stack; that residual is accepted given the window is now one function call, not a cache TTL (core-dump suppression at the runtime level is a `../04-infra-devops/CLAUDE.md` mechanism, requirement recorded here, not owned here). The token field is excluded from any generic request/response dump used in error handling — the same log-hygiene discipline below applies to panic traces and debug dumps, not just application logs.
21 - **The outbound HTTP client to the vendor must construct a fresh, isolated `Authorization` header per request, never inherited from connection reuse.** Connection pooling/keep-alive is the normal default in any HTTP client library; a pooled client that reuses a request-context object or fails to reset the auth header per call can attach request A's vendor token to request B's outbound call — the same cross-tenant leak as a cache-key collision, with no cache required. This is a required implementation discipline, not an assumption left implicit.
22 - **Scoped narrowly if the vendor supports it** — request only the scope needed for `/identity`, not a broad token, so a leaked token's blast radius is bounded by the vendor's own authorization model as well as ours.
23 - **Returned once, to the authenticated internal caller only — never to end users, never persisted server-side (resolved by the cross-track consistency pass, F3).** Our own `/auth` returns the vendor `access_token` to whichever authenticated `connector:identity-lookup`-scoped caller invoked it (in this project's shape, `cmd/api-service` acting on an onboarding flow's behalf) — this is what the assignment's `/auth` contract actually requires ("retrieves an `access_token`"), and it is what `../01-product-industry-research-design/ux-notes.md`'s onboarding flow depends on to bridge its two user-facing steps. Because our own `/identity` request body is fixed by the assignment to `{"phone", "name"}` with no credential field, the vendor token travels as a request header on the subsequent `/identity` call (e.g. `X-Vendor-Access-Token`) — this doesn't violate the fixed JSON contract, which governs the body only, not headers. The connector itself still never caches or persists the token (S3's no-cache ruling stands unchanged): each `/identity` call's presented token is used once and discarded, exactly as if it had been freshly fetched, and it is the caller's responsibility — not the connector's — to hold the token for the short span between the two calls. If a caller presents no token, or an expired one, `/identity` fails closed with a generic error that does not distinguish "no token" from "bad token" from "vendor rejected it" (same user-enumeration discipline as §4's other failure-handling rules).
24
25 ### 2. How `/identity` calls are authenticated to the vendor
26
27 - Standard bearer-token usage: the vendor `access_token` from `/auth` presented as an `Authorization: Bearer` header to the vendor's identity endpoint (or whatever the vendor's own contract specifies — this connector adapts to the vendor, not the reverse).
28 - **Token refresh/re-auth on expiry** handled by the connector transparently — a caller of our `/identity` endpoint should not need to know or manage vendor token lifecycle.
29 - **Vendor calls over TLS only**, certificate validation never disabled, including in local/dev environments (repeating the threat model's point because it's the most common place a "temporary" dev bypass ships to production unnoticed).
30 - **Our own credentials to call the vendor's `/auth`** (this connector's client id/secret with ABC/XYZ, if the vendor requires connector-level registration in addition to the end-user's own username/password) are runtime-injected secrets, never in source or config committed to the repo — requirement set here, mechanism owned by `../04-infra-devops/CLAUDE.md`.
31
32 ### 3. What this service should and shouldn't log or persist
33
34 | Data | Log it? | Persist it? |
35 |---|---|---|
36 | End-user's vendor username | No (identifier, but low sensitivity relative to password — still avoid in plaintext logs; use it only for correlation via a hash if audit trails need it) | Only if a legitimate business reason exists; not by default |
37 | End-user's vendor password (`/auth` request body) | **Never, under any circumstance, including error paths and stack traces** | **Never** |
38 | Vendor `access_token` | **Never** | Only as short-TTL encrypted cache per above; never in plaintext, never in application logs |
39 | PII returned by `/identity` (name, phone, address fields) | Never the field values; log only the *fact* of a lookup (caller identity, timestamp, success/failure, which record was resolved by opaque id) | Retention/caching policy for this PII is `../05-data-ops/CLAUDE.md`'s call — this track's requirement is that whatever is persisted follows the same encryption-at-rest and access-control bar as `user_profile` data, no weaker because it arrived via a connector rather than direct input |
40 | Vendor error responses | Log status/error-code only; do not log raw vendor response bodies by default, since a vendor error payload can itself echo back submitted PII or partial credentials | N/A |
41
42 ### 4. Failure handling as a security control, not just reliability
43
44 - **Vendor auth failures must not leak which part of the credential was wrong** (username vs. password) in whatever this connector surfaces upward — same user-enumeration concern as the threat model's credential-stuffing row, now facing outward at whoever calls our `/auth` passthrough.
45 - **Retry/backoff on vendor calls** should be rate-limited on our side independent of the vendor's own limits, so a compromised or careless caller can't use our connector to hammer the vendor (which is both an availability risk to us and a way to get our connector's vendor credentials rate-limited or banned).
46 - **Circuit-breaking**: if the vendor is failing or behaving anomalously (e.g., returning tokens for identities that don't match the request), fail closed — do not fall back to a cached or stale identity result silently, since that risks serving stale PII as if freshly verified.
47
48 ### 5. Authentication and authorization of calls *into* this connector
49
50 Found by Ingrid Solano's S8 independent review — §1–§4 above are entirely about protecting the *vendor* token and the *vendor* call; none of them state who is allowed to call *our* `/auth` and `/identity` in the first place. Fixed here rather than left as a silent assumption:
51
52 - **Callers of `cmd/idp-connector`'s own `/auth` and `/identity` must be authenticated**, using the same OAuth2 client-credentials → JWT bearer mechanism as `api-auth-design.md`, but with a **distinct audience** (`aud: idp-connector-service`, separate from `loginid-api-service`) and its own scope (`connector:identity-lookup`) — this is a different threat model and a different, smaller caller set (internal services, not question 2's broader API clientele), so it gets its own audience rather than reusing the search API's. Only a service that legitimately mediates an end-user or business flow needing vendor identity data (in this project's shape, `cmd/api-service` acting on a caller's behalf) should hold this credential — it is not exposed to arbitrary external callers.
53 - **Why this matters concretely:** without it, an unauthenticated caller could hit our `/auth` passthrough with arbitrary vendor credentials all day and use this connector as exactly the credential-testing oracle §4's rate-limit control is meant to prevent — but "rate-limited per calling-API-client" has nothing to bind "per calling-API-client" to if no caller identity is required to reach the endpoint at all.
54 - **Audit logging** for calls into this connector follows the same discipline as §3's table: caller `sub`, scope, timestamp, outcome — never the vendor credentials or PII in the request/response bodies.
55
56 ### 6. What a vendor token authorizes at `/identity` — an assumption, stated explicitly
57
58 Also found by S8 review. This design's token-handling discipline (§1) is entirely about *custody* of the vendor token — encryption, memory scope, never-log. It says nothing about what the token is allowed to *do*: specifically, whether a vendor `access_token` obtained from one person's `/auth` call authorizes an `/identity` query for a *different* person's `name`/`phone`, or only for the token-holder's own identity.
59
60 This connector cannot enforce that boundary itself — the assignment's generic `/identity` contract (`{"phone": "...", "name": "..."}`, no scoping field) puts that decision entirely on the vendor's own API. **Stated assumption, not a resolved control:** this design assumes a real ABC/XYZ-shaped vendor binds `/identity` queries to the authenticated principal at the vendor side (i.e., the vendor's own API rejects a query for an identity other than the token-holder's). If that assumption doesn't hold for a given real vendor, this connector becomes a lookup oracle for anyone who can obtain one valid vendor token, and no control in §1–§5 touches that risk, because none of them govern token *authority* rather than token *custody*. This is the same class of question as the search API's object-level authorization (`api-auth-design.md`, S4) and belongs on `../01-product-industry-research-design/CLAUDE.md`'s list when evaluating which real vendor ABC/XYZ represents — this track cannot resolve it without knowing a concrete vendor's actual token semantics.
61
62 ## What this design does not resolve
63
64 - Data retention duration for cached `/identity` results — `../05-data-ops/CLAUDE.md`.
65 - Which secrets manager holds this connector's own vendor credentials — `../04-infra-devops/CLAUDE.md`.
66 - Vendor selection/evaluation (which real-world providers "ABC"/"XYZ" might represent) — that's product framing in `../01-product-industry-research-design/CLAUDE.md`, not a security question.
67
68 ## Sources
69
70 - OWASP API Security Top 10 2023, API7 (Server Side Request Forgery) and API8 (Security Misconfiguration) — both relevant to a service that makes outbound calls to a third party on a user's behalf: https://owasp.org/API-Security/editions/2023/en/0x11-t10/
71 - OWASP Logging Cheat Sheet (what not to log — credentials, tokens, PII): https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
72
73 ## Attack-tree table — connector token lifecycle (S3 red/blue, 2026-09-12)
74
75 Full positions, rebuttals, and ruling: `decisions/connector-token-lifecycle-redblue.md`. Table shape per `planning-approach.md` Appendix A.
76
77 | # | Leaf (attack path) | Precondition | Likelihood | Impact | Blue response: Control *or* Accepted risk | Owner | Residual | Source |
78 |---|---|---|---|---|---|---|---|---|
79 | 1 | Cache-key confusion → cross-tenant token/PII leak via request-derived cache key | Persistent cache (eliminated by default posture below) | M | H | **Eliminated by design:** no cache by default (Felix Adebayo, alternatives seat) — no persistent cache means no key to collide on. **Control, exception-path only:** if caching is ever re-opened, key must derive from immutable vendor-issued ids, never request-supplied `name`/`phone`. **Control (this run, Tomasz's rebuttal):** the connection-reuse variant of this leaf survives even with no cache — a pooled HTTP client that doesn't reset `Authorization` per call can attach one request's token to another. Closed with a required control: fresh, isolated auth header constructed per outbound request, never inherited from connection reuse. | 03 (client construction; cache-key derivation if exception ever opened) | **L** — storage variant eliminated; connection-reuse variant closed by an explicit, checkable implementation requirement | OWASP API8:2023; judgment (connection-reuse variant) |
80 | 2 | Fail-open on KMS unavailability during a caching window | Persistent cache with KMS-backed encryption at rest (eliminated by default posture) | M | H | **Eliminated by design:** no cache by default removes the write-time KMS dependency from the hot path. **Control, exception-path only:** if caching is ever re-opened, KMS-unreachable fails closed to "no cache, fetch live," never to an unencrypted write. Tomasz conceded this leaf outright — no hole found. | 03/04 (KMS mechanism is 04's) | **L** | NIST SP 800-57 |
81 | 3 | Vendor token replay — no proof-of-possession/session binding once decrypted for use | Token exfiltrated by any other means within its exposure window | M | H | **Accepted risk.** Bearer tokens under RFC 6750 are possession-equals-authorization by design; PoP/binding (DPoP, RFC 9449; mTLS-bound tokens, RFC 8705) needs vendor-side support this project cannot assume for a generic ABC/XYZ vendor the assignment never specifies. Acceptable *for this system* because fetch-per-call bounds the exposure window to one request's execution scope — no standing cached credential to steal. **Tightened per Tomasz's rebuttal:** the boundary is stated explicitly — fetch → use in the immediately following `/identity` call → zeroize; a retried `/identity` call re-fetches via `/auth` and never reuses a token from a prior attempt, so the accepted window cannot silently stretch to the length of the retry/backoff policy. | — (re-review trigger: a PoP-capable vendor surfacing in 01's research) | **M** — accepted, not eliminated; a real vendor-side constraint, not a design gap, but still "possession = authority" for the fetch's lifetime | RFC 6750 §5; RFC 9700 §2.5 |
82 | 4 | Process-memory/crash-dump exposure of the plaintext token before encryption | Attacker has read access to a crash artifact or swap on the host | L | M | **Control:** token field excluded from any generic request/response dump used in error handling (panic traces, debug dumps — same discipline as application logs); token variable scoped to the narrowest function possible, never passed up the call stack beyond the vendor-call function. **Residual accepted:** Go gives no guaranteed memory zeroing without unsafe/cgo, so a crash dump taken mid-call could still capture it; acceptable given the window narrowed from a cache TTL to one function call (Felix's alternatives turn). Core-dump suppression at the runtime level is a 04 mechanism, requirement recorded, not resolved here. | 03 (code discipline) / 04 (runtime config) | **L** — narrowed from a standing cache exposure to a single function-call window | OWASP Logging Cheat Sheet |
83 | 5 | Connector used as a credential-stuffing oracle against the vendor (rate-limit granularity unstated) | No per-identity/per-IP rate-limit granularity stated in the original design | M | M | **Control:** rate limit keyed per calling-API-client *and* per source IP, with a stricter threshold on vendor-auth *failure ratio* specifically (credential stuffing shows as a high failure ratio before high volume), paired with exponential backoff per identity attempted, not just per client — closes the granularity gap without adding a new requirement beyond what §4 already gestured at. | 03 (rate-limit logic) / 04 (shared rate-limit store or edge mechanism, if enforced at a gateway) | **L** | OWASP API4:2023; NIST SP 800-63B §5.2.2 |
84
85 Rules of use followed: Tomasz ranked and handed over his top five before Helena answered (per `CASTING.md` §5 ⟨02⟩); Felix's alternatives turn sat between red and blue and eliminated leaves 1's storage variant and 2's substrate by design change rather than control; Helena answered every leaf with a control or a justified accepted risk; Tomasz's rebuttal turn on the top three surfaced one unaddressed variant (leaf 1's connection-reuse case) and one boundary needing explicit statement (leaf 3), both resolved without reopening the ruling; Marcus assigned Residual with the justification in each row. No Residual **H** — none of the five leaves cleared that bar after blue's response and the rebuttal-turn tightening, so no follow-up Story is required; leaf 3's **M** is the one residual worth Warren's attention as a genuine accepted risk, not a gap.
86
87 ---
88 Model: sonnet (Tomasz Wrede, red; Felix Adebayo, alternatives; Helena Marsh, blue) / sonnet (Marcus Ilori, lead, ruling and residual scoring). Turns consumed: 5 (red top-five, alternatives, blue, rebuttal, lead residual scoring) — matches the 4-turns-plus-lead budget in `PLAN.md` §3.
89 {
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/handoff-03-auth.md"
}1 # Hand-off 02 → 03: Auth scheme, validation point, and authorization requirements 2 3 Status: **v3** — updated after the cross-track consistency pass (`../decisions/cross-track-consistency.md`) resolved six findings owned by this track that touched this hand-off (F3, F5, F6, F8, F10, F-pag, F52); v2's S8-driven fixes are unchanged and folded in below as before. Receiver: Renata Cole, `../03-engineering-delivery/`. Written so 03 should be able to scaffold and build the middleware without re-deriving the reasoning in `api-auth-design.md`, `connector-security.md`, or the S3/S4/S8 decision records. Full reasoning, if wanted: `api-auth-design.md`, `decisions/search-authz-scoping.md`, `decisions/connector-token-lifecycle-redblue.md`. 4 5 ## Token scheme 6 7 - **Grant type:** OAuth2 client-credentials. Machine-to-machine only; no end-user login flow for question 2's API. 8 - **Token format:** JWT, signed, asymmetric (RS256 or ES256 — pick one and use it consistently; RS256 is the safer default if your Go JWT library's ES256 support is less mature). 9 - **Claim set (minimum):** `sub` (calling client identity), `scope` (space-delimited, values below), `exp`, `iat`, `aud` (this API, specifically — reject tokens minted for another audience). 10 - **Literal values, filling the "pending 02's final scheme" placeholder in your Service boundaries config surface:** `AUTH_JWT_ISSUER = "https://auth.loginid-takehome.internal"`, `AUTH_JWT_AUDIENCE = "loginid-api-service"`. Both are configuration, not secrets — safe in a `ConfigMap` or committed default, per `handoff-04-secrets.md` row #3. 11 - **TTL:** 5–15 minutes. No refresh tokens — the client re-authenticates with its own client secret on expiry. 12 - **No denylist.** Revocation is handled by TTL shortness, not a stateful lookup. Don't build one; it's not missing, it's a deliberate trade-off already reasoned through in `api-auth-design.md`. 13 - **Verifier must pin the expected algorithm** (whichever of RS256/ES256 you choose) and reject a token whose header claims any other algorithm, including `alg: none` and a downgrade to a symmetric algorithm using the public key as an HMAC secret (RFC 8725 §3.1). Check your JWT library's default behavior — several popular libraries have shipped this vulnerability by not doing this by default. 14 - **Token-endpoint brute-force protection**, separate from the resource-API rate limits below: failed-grant rate limiting and progressive backoff per `client_id` and per source IP on the token-issuing endpoint itself, plus alerting on a sustained run of failed grants against one `client_id`. This protects the endpoint where a caller presents `client_id`/`client_secret`, which none of the resource-API limits below cover. 15 - **Bearer-token replay is an accepted risk, not a gap to close in implementation.** This token has the same possession-equals-authority property as the connector's vendor token (`connector-security.md`); no PoP/binding is expected or required here. Noted so it isn't mistaken for something to "fix" during implementation. 16 17 ## Where it's validated 18 19 - **Middleware at the API edge**, in `internal/api/` per your published service boundary — every request to `cmd/api-service` passes through token validation before reaching a handler. `cmd/idp-connector` does not validate *this* token — but per `connector-security.md` §5 (added by S8 review), `idp-connector`'s own `/auth` and `/identity` handlers need the same JWT middleware pattern, with a **distinct audience** (`aud: idp-connector-service`, not `loginid-api-service`) and its own scope (`connector:identity-lookup`), restricted to internal callers (in this project's shape, `api-service` acting on a caller's behalf) — not the same token, not exposed to arbitrary external callers. Two audiences, one mechanism. 20 - **Key retrieval:** middleware verifies with the **public** key only (JWKS or a fetched public key — see `handoff-04-secrets.md` row #3). The private signing key never touches `api-service`; it lives with the token-issuing function, which is a mode of `api-service` (not a separate binary — see `handoff-04-secrets.md` Assumption 2), scoped by RBAC so the verification code path and the issuing code path don't share Secret access even though they're the same binary. 21 - **Key rotation:** expect a `kid` claim; publish old and new public keys together during rotation overlap. Token TTL (5–15 min) bounds how long that overlap needs to last. 22 23 ## Scope vocabulary (final, from S4 — `decisions/search-authz-scoping.md`) 24 25 | Scope | Authorizes | Does not authorize | Object-level check | 26 |---|---|---|---| 27 | `profile:read:own` | Retrieve a single record by id the caller already holds a legitimate reference to (e.g., a partner resolving a referred user) | Search/query by name or phone fragment; retrieval of a record outside the caller's referral relationship | `target.id` checked against the `caller_referral` mapping (see "Where `caller_referral` and the caller-role table live," below); out-of-set → reject, logged as a policy denial, distinct from an auth failure | 28 | `profile:read:any` | Retrieve a single record by id, no referral constraint | Search/query by fragment | Requires a `reason_code` from a closed enum (below) on every request, logged with the audit record | 29 | `profile:search` | Query by name/phone fragment across the table | — (this is the broadest scope; there is no broader one) | Result cap enforced; a query that would exceed it unconstrained (e.g., bare wildcard) is rejected outright, not truncated | 30 31 `reason_code` enum for `profile:read:any` (closed, versioned — adding a value is a documented change, not a runtime config toggle): `support_ticket`, `fraud_review`, `kyc_reverification`, `legal_hold`. 32 33 **Object-level policy hook implementation shape:** a function `authorize(sub, scope, target) -> allow|deny`, called after scope validation, before the handler executes the query/retrieve. This is a second check, not a replacement for the scope check — a valid `profile:read:own` token can still fail `authorize()` if the target isn't in the caller's referral set. **Confirmed placement (per your reply): an explicit call inside each handler right after parsing the request and before the DAO call, not a second middleware** — correct, since `authorize()` needs the parsed target id, which a pre-routing middleware doesn't have. **Enforcement site for the `profile:search` result cap: the same in-handler call site, checked before the query is built** — a query that would exceed the cap unconstrained is rejected there, not truncated after the DAO returns rows. 34 35 **Where `caller_referral` and the caller-role table live (resolves F8 — this data was previously asserted as "owned by `../05-data-ops/`" without 05 ever having received that assignment; 05's schema has no such table and shouldn't grow one for this).** Both live in the token-issuing authorization server's own datastore (the same one that holds the hashed `client_secret`s per `handoff-04-secrets.md` row #4), not in `05-data-ops`'s `user_profile`/`user_credential` schema. This is authorization-provisioning data — who a client credential is allowed to act on behalf of — not subject data, and keeping it out of 05's schema also keeps it out of that schema's PII-governance/retention rules, which don't apply to it. 05 owns nothing here; this hand-off's earlier wording was wrong to say otherwise. 36 37 **Response shape and masking (resolves F6 — 01's product requirement had no mechanism to bind to).** `profile:search` responses carry masked PII fields by default (e.g., a phone shown as `***-***-1234`, an address reduced to `locality`/`region` only) — unmasked, full-field detail is obtained only by a follow-up call under `profile:read:own` or `profile:read:any` against the specific record id returned by the search, which is its own separately-audited call per the audit-log section below. This uses scopes that already exist rather than inventing a fourth "expand" concept: masking is a `profile:search`-response property, not a per-call flag, and "tighter than search" is satisfied structurally because `read:own`/`read:any` are already narrower-provisioned scopes than `search`. 38 39 **Reject an empty search body before the DAO (resolves F10, first half).** A `profile:search` request with no `name` or `phone` filter at all is rejected by the handler with a 400 before it ever reaches the DAO — the DAO's own "an all-nil query is legal and returns every profile" behavior (`../05-data-ops/multi-db-strategy.md`) is a DAO-layer property this API must not expose directly. This is a shape-based rejection, distinct from and in addition to the count-based result-cap rejection above. 40 41 ## Pagination, rate limits, decomposition resistance (requirements — numbers are fixed, mechanism is yours) 42 43 - **Opaque cursor at the API boundary** (resolves F-pag — the earlier "cursor-based pagination only, no offset-based paging" was wrong to imply offset itself is the enumeration risk: a keyset cursor permits the same page-by-page walk, just statefully; the actual control is the cumulative counter below, and 05's DAO contract is `Offset int`, not keyset). The API-facing cursor may encode the DAO's offset directly — callers cannot construct or walk one by hand, which is the property the boundary exists to give you, and the DAO's offset is never caller-visible. **Enforcement site: the cursor-encoding/decoding step in the handler layer**, opaque to the caller regardless of what it encodes underneath. Default page size 20, hard ceiling 50 (05's DAO ceiling is 10,000 — this API's ceiling is the tighter, caller-facing one; don't expose the DAO's). 44 - **Per-client-credential rate limits:** 60 req/min for `read:own`/`read:any`; 10 req/min for `profile:search`. **Enforcement site: rate-limiting middleware in `internal/api/`, keyed per client credential (`sub` claim).** 45 - **Cumulative distinct-record-touch counter per caller per rolling 24h window**, tracked independently of per-minute rate — this is the control that actually resists decomposition-style enumeration (not the pagination scheme, per the F-pag correction above). **Applies to `profile:read:any` as well as `profile:search`, unconditionally on whether a `reason_code` is present** — a valid, logged `reason_code` satisfies the per-call `authorize()` policy check but does not exempt the caller from this cumulative cap (S8 review fix: the original wording made this counter a no-op for `read:any`, since that scope requires a `reason_code` on every call by design). **Enforcement site: a counting store (Redis, in-process with periodic flush, whatever fits your stack) incremented once per distinct record id returned, checked before the response is sent** — the mechanism is your call; the requirement is that it exists, applies to both scopes, and alerts on threshold, not just hard-blocks. 46 - Sustained near-limit usage on any of the above should alert, not just silently throttle. **Enforcement site: the same rate-limiting middleware and counting store above, not a separate mechanism.** 47 48 ## Audit-log field list (every search/retrieve call) 49 50 Log these fields, and only these — see `handoff-04-secrets.md`'s never-log list for what must never appear here: 51 52 - `sub` (caller identity) 53 - `scope` used 54 - record id(s) touched (opaque ids, not the PII values) 55 - `reason_code`, when the scope is `profile:read:any` 56 - policy decision (`allow` / `deny`, and if `deny`, whether it was a scope failure or an `authorize()` policy denial — these are distinct signals for detection) 57 - timestamp 58 - request outcome (success/failure/rate-limited) 59 60 Never in this log: PII field values, full JWTs, full request/response bodies. Per `handoff-04-secrets.md`'s new requirement, this log stream itself needs read access distinct from and narrower than whatever can read Secrets — that's 04's mechanism, but don't let the log ship without asking whether that separation exists. 61 62 ## What's not yours to decide (already decided upstream, don't re-derive) 63 64 - **Grant type, token format, and claim set** — decided in `api-auth-design.md`; not open for re-litigation during implementation. 65 - **Scope vocabulary and provisioning bars** — decided in S4 (`decisions/search-authz-scoping.md`); if a real implementation constraint makes one of these unworkable, flag it back to me rather than adjusting it unilaterally. 66 - **The `DB_DSN_FILE` convention** — per `handoff-04-secrets.md`'s S7 revision, this track is asking you to add a file-based alternative to `DB_DSN` (env var becomes a transitional fallback, not a permanent equal option), because a DSN's embedded password is exposed by `kubectl describe`/crash dumps/child-process environment the same as any other secret in that inventory. This is a request into your config surface, not a unilateral change on my part — you decide the exact mechanism (a `_FILE` suffix convention or otherwise), I'm asserting the requirement. 67 - **Password/client-secret hashing** — Argon2id, decided in `threat-model.md` Asset 1; not this hand-off's concern beyond noting it exists. 68 - **PII retention/caching duration for anything the API returns** — `../05-data-ops/CLAUDE.md`'s call, not this track's or yours. 69 - **Whether `read:any` ever gets issued to a real caller** — per S4, the scope stays defined but unissued unless a caller clears the provisioning bar (caller-role table entry, named approver). Don't wire a default grant "just in case." 70 71 ## Open question back to you 72 73 Nothing blocking. One thing worth a quick confirm when convenient: does your `internal/api/` middleware layer have a natural place to run the object-level `authorize()` check as a distinct step from scope validation (e.g., a second middleware or an explicit call inside each handler before the DAO call), or would you rather this track propose a specific Go interface shape for it? Either is fine — say which you'd find more useful and I'll adjust. 74 75 --- 76 Model: sonnet (Marcus Ilori, lead — compiled directly from S4's ruling, no new debate). Turns consumed: 0 hire turns (synthesis of already-ruled decisions per `PLAN.md` S6). 77
{
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/handoff-04-secrets.md"
}1 # Hand-off 02 → 04: Secrets inventory and never-log list 2 3 Status: **v2** — v1's S7 objection turn and 04's Newcomer cold read are both resolved and folded in below; this revision additionally resolves four findings from the cross-track consistency pass (F12 this version label, F13 the signing-key custody contradiction, F15 a missing credential row, F50 two never-log gaps). Receiver: Theo Bergman, `../04-infra-devops/`. Written to the project's hand-off standard: 04 should be able to act on this without re-deriving the reasoning in `threat-model.md`, `api-auth-design.md`, or `connector-security.md`. 4 5 ## Assumptions this hand-off makes 6 7 1. Deployment target is the **design-only Kubernetes lab cluster** 04 names (Warren's decision via Theo, `../LOG.md`). Delivery mechanism is therefore Kubernetes-native `Secret` objects unless 04 chooses an external store; every requirement below is written so it still holds if the mechanism changes. 8 2. Service topology is 03's published boundary (`../PLANNING.md`, Service boundaries): two binaries, `cmd/api-service` (Q1 DAO + Q2 REST API) and `cmd/idp-connector` (Q3), env-var configuration only. Variable names below are 03's; two are marked as pending this track's final scheme. **The token-issuing authorization-server function is a mode of `api-service`, not a third binary** — this take-home's scope doesn't justify a standalone authz server, and 03 has published no third `cmd/` entrypoint. **Corrected by the cross-track consistency pass (F13):** "a mode of the same binary" is not "the same Kubernetes Deployment," and the earlier text here was wrong to imply RBAC alone separates them — a Kubernetes Role restricting `get` on a Secret has no effect on a volume already mounted into a container, so if `api-service`'s one Deployment mounted the signing key, every replica (verification path included) would have it on disk regardless of RBAC. **The fix: the issuer runs as a second Deployment of the same `api-service` image**, selected by an environment variable 03 adds to the config surface (e.g. `APP_MODE=issuer` vs. the default, consistent with the surface's env-var-only, no-flags rule), with **its own ServiceAccount that alone mounts the signing-key Secret** (row #2) — the `api-service` Deployment serving verification/search mounts nothing from row #2. This is a request into 03's Deployment/mode surface, not a unilateral change on my part; flag to me if the mode-selection mechanism needs to differ. 9 3. An **external operator agent** — a human or automated actor outside this project, holding cluster-admin or namespace-admin rights on 04's design-only lab cluster, who applies Kubernetes manifests to it — is a privileged actor outside this service: anyone who can apply a manifest can read any Secret it mounts. Consequence for 04: manifests reference Secrets by name and never carry values; values are set out-of-band. This is a named trust boundary in `threat-model.md`, not a silent assumption; nothing further about who that operator is needs to be re-derived from 04's own files to act on this hand-off. 10 4. Nothing is deployed and nobody contacts the cluster or its operator during the take-home. This document describes what *would* be delivered, not what has been. 11 12 ## Secrets inventory 13 14 | # | Secret | Used by | Injection requirement (02 sets it) | Rotation expectation | Kubernetes-native note (04's mechanism) | Never-log | 15 |---|---|---|---|---|---|---| 16 | 1 | Database credentials, carried inside `DB_DSN` | `api-service` | Runtime-injected; never in source, image, or committed config. **Required, not preferred: file mount via a `DB_DSN_FILE` convention** (03 to add alongside `DB_DSN`), because a DSN's embedded password is exposed by `kubectl describe`, crash dumps, and child-process environment exactly as much as any other secret in this table — there is no citation that makes `DB_DSN` a lesser case, so this track is not treating it as one | On credential rotation; service restart or reload acceptable | `Secret` mounted read-only as a file, read via `DB_DSN_FILE`; env-var `DB_DSN` is a fallback only until 03 ships the file-based convention, not a permanent equal option | Yes — the DSN contains the password; log the driver name and host at most | 17 | 2 | JWT signing **private** key (RS256/ES256), per `api-auth-design.md` | The issuer Deployment (`api-service` image, `APP_MODE=issuer`) only — **not** the `api-service` Deployment that verifies with the public key; these are two Deployments of one image, not one Deployment doing both (F13 fix) | Runtime-injected as a **file**, never an env var; asymmetric so the verifying Deployment never holds it | Key rotation with a `kid` claim; publish old and new public keys together during overlap; TTL of tokens (5–15 min) bounds the overlap window | `Secret` mounted read-only as a file, **only into the issuer Deployment's pod spec** (not the verifying Deployment's); RBAC restricts `get`/`list` to the issuer's own ServiceAccount | Yes | 18 | 3 | JWT **public** key / JWKS | `api-service` middleware | Not a secret; may ship in config or be fetched from the issuer | Follows #2 | Plain `ConfigMap` or fetched URL; listed here so 04 does not over-protect it | No (but never log full tokens — see below) | 19 | 4 | API caller `client_secret`s (OAuth2 client-credentials) | Authorization server (verifies); callers hold their own copy | **Stored hashed** (Argon2id, same floor as `threat-model.md` Asset 1), never plaintext at rest; issued once at provisioning and not recoverable | Per-client rotation on demand; revoke by deleting the hash | Lives in the issuer's datastore, not a Kubernetes `Secret`; 04 delivers only that datastore's credentials (#1-shaped) | Yes | 20 | 5 | Connector's own vendor registration: `IDP_ABC_CLIENT_ID`, `IDP_ABC_CLIENT_SECRET` (and the XYZ pair if a second vendor is configured) | `idp-connector` | Runtime-injected; never in source or committed config; `client_id` is low-sensitivity but travels with the secret and gets the same handling | Per vendor's policy; expect manual rotation | One `Secret` per vendor so rotating ABC never touches XYZ; file mount preferred | Yes for the secret; keep the id out of logs too | 21 | 6 | Vendor `access_token` (obtained via `/auth`, used for `/identity`) | `idp-connector`, in memory | **Not a deployed secret** — transient. If cached at all: TTL ≤ vendor expiry, encrypted at rest with #7, keyed per vendor account (`connector-security.md` §1) | Expires on its own; never persisted across restarts | No `Secret` object; if 04 provisions a cache, it must be encrypted using #7 and reachable only from `idp-connector` | Yes — never, including error paths | 22 | 7 | Data-encryption key (KEK) for envelope encryption of any TOTP shared secrets and any cached vendor tokens | `api-service` (TOTP) — **not a committed feature of this submission**; `user_credential.method` is modeled as an extensible lookup table (`../05-data-ops/multi-db-strategy.md`) that supports TOTP as a *possible* value, but no TOTP implementation is planned unless 03 builds one. `idp-connector` (token cache) — **also not committed**: S3's red/blue on the connector token lifecycle (in progress) is deciding whether the connector caches vendor tokens at all; current direction (Felix Adebayo's alternatives turn) is "no cache by default, fetched fresh per `/identity` call," which would remove this use case too. This row is provisioned only if either feature materializes — it is a contingency the inventory names in advance, not a live requirement for 04 to build now | Runtime-injected as a file; the KEK never leaves the process that decrypts | Rotation by re-wrapping DEKs; never requires re-hashing passwords (those are hashed, not encrypted) | `Secret` (file mount) or an external KMS if 04 prefers — the requirement is only that a KEK exists and is not in source | Yes | 23 | 7a | Credential to reach the external KMS, **only if 04 exercises the KMS alternative in row #7** (a role, API key, or client cert — whichever the chosen KMS uses) | Whichever binary holds the KEK reference (#7) | Same discipline as every other row here: runtime-injected, never in source, never provisioned ad hoc outside this table | Per the KMS provider's own rotation mechanism | Cloud-KMS access is typically workload-identity-based (no static credential to store) rather than a `Secret` object; if the chosen KMS instead requires a static credential, it gets its own `Secret` under the same RBAC discipline as row #2 | Yes | 24 | 8 | TLS serving private keys for `api-service` and `idp-connector` | Ingress / each binary, per 04's termination choice | Runtime-injected; TLS 1.2+ only; certificate validation never disabled in any environment (`connector-security.md` §2) | Per certificate lifetime; automated issuance preferred | `kubernetes.io/tls` `Secret`; issuance mechanism is 04's call | Yes | 25 | 9 | End-user's vendor password (`POST /auth` request body) | `idp-connector`, transient | Never persisted, never cached, held only for the vendor call | n/a | Nothing for 04 to deliver; listed so nobody creates a place for it | **Yes — under no circumstance, including stack traces** | 26 | 10 | `api-service`'s own client credential (`client_id`/`client_secret`) toward the issuer, scoped `connector:identity-lookup`/`aud: idp-connector-service` — required for `api-service` to call `idp-connector`'s own authenticated `/auth`/`/identity` (`connector-security.md` §5) | `api-service` (the Deployment serving question 2's API, acting as a caller of the connector on a user's behalf) | Runtime-injected; never in source or committed config — same discipline as row 5's vendor registration, added by the cross-track consistency pass (F15), which found this credential asserted as a requirement in `connector-security.md`/`handoff-03-auth.md` with no inventory row | Same per-client rotation as row 4 (revoke by deleting the issuer-side hash) | File mount, one `Secret`, `api-service`'s own ServiceAccount | Yes | 27 28 Names marked pending in 03's boundary: `AUTH_JWT_ISSUER` / `AUTH_JWT_AUDIENCE` are configuration, not secrets; this track will confirm their final values in `handoff-03-auth.md`. They are listed here only so 04 does not classify them as secrets. 29 30 ## Kubernetes-specific requirements (04 implements; 02 sets) 31 32 - **Secrets at rest in etcd are base64, not encrypted, by default.** Either enable encryption at rest for the `Secret` resource type, or source values from an external store that never writes plaintext to etcd. One or the other; the design must say which. 33 - **RBAC:** no service account may `get`/`list` Secrets it does not mount. The operator agent's apply rights are a documented trust boundary, not a hole to close in this take-home — but the design should note that its scope could be narrowed to a namespace. 34 - **Manifests carry references, never values.** Any manifest in the repository contains Secret *names*; values are provided out-of-band. This is what keeps assumption 3 honest. 35 - **Prefer file mounts over env vars** for #2, #5, #7, #8. #1 is env-var only because 03's config surface is env-var only; if 03 accepts a `_FILE` suffix convention, move it. 36 - **No secret in an image layer, build arg, or CI log.** CI redaction is 04's mechanism; the requirement is that a build log can be published without exposing anything in this table. 37 - **Audit/access logs (the output of the never-log list below) are readable only by an incident-review role, distinct from and narrower than general operator access.** `sub`, scope, record id, and timestamp are enough on their own to reconstruct who looked up whom — that log stream needs its own read restriction, separate from whichever service account can `get`/`list` the Secrets in this table. Mechanism (log-pipeline RBAC, a separate log sink with its own access control, etc.) is 04's call; the requirement is that "can read Secrets" and "can read the audit trail" are not the same grant. 38 39 ## Never-log list (applies to every component, every environment, every log level, including error paths and stack traces) 40 41 1. End-user vendor password (#9). 42 2. Vendor `access_token` (#6) — and any `Authorization` header value, inbound or outbound, in full. 43 3. PII field values from `user_profile` or from `/identity` responses: name, phone, `street_address`, `locality`, `region`, `postal_code`, `country`. Log the *fact* of access — caller `sub`, scope used, opaque record id, timestamp, status — never the values. 44 4. Raw vendor error response bodies (they can echo submitted PII or partial credentials). Log status code and vendor error code only. 45 5. JWT signing private key (#2) and any JWT in full — log `sub`, `aud`, `exp`, and `kid` if needed, never the token. 46 6. API caller `client_secret`s (#4), in plaintext or hashed. 47 7. `DB_DSN` (#1) or any connection string containing credentials. 48 8. Full request bodies of `POST /auth` and `POST /identity` — both contain items above by construction. 49 9. Search query parameters containing a name or phone fragment (`api-auth-design.md`, hardening) — they are PII; redact or hash before any access log, including proxy and ingress logs 04 controls. 50 10. **Database driver error messages and `DETAIL`/`Key (...)=(...)` text** (added by the cross-track consistency pass, F50 — the most probable PII-to-log path in the system and previously named by no item on this list). A Postgres CHECK or unique-constraint violation on a `user_profile`/`user_credential` write echoes the failing row's column values, or the offending key value, in its error `DETAIL`. Log SQLSTATE/result code and constraint name only — the sanitization happens in 03's error-translation layer (`error-semantics.md`) before the error is wrapped for logging, not after. 51 11. **Username** (added by the same pass, F50) — typically an email address, and previously ungoverned by this list even though it can appear in `ErrDuplicateUsername` driver detail (item 10) or in a log line correlating a failed login. Treat it with the same discipline as a PII field value (item 3): log a hash or an opaque identifier for correlation, never the raw value. 52 53 ## What 04 does not decide (already decided upstream) 54 55 Token TTLs and claim set (`api-auth-design.md`); scope vocabulary (`handoff-03-auth.md`, pending); password and client-secret hashing (`threat-model.md` Asset 1); whether the connector caches vendor tokens (`connector-security.md` §1 — it may, under the conditions in #6); PII retention windows (`../05-data-ops/pii-governance.md`). 56 57 ## S7 objection turn — Ingrid Solano, resolved 58 59 All four objections accepted; no decline. 60 61 1. **Authorization-server ownership gap.** Accepted. Assumption 2 now states the authz-issuing function is a mode of `api-service`, scoped by a Role narrower than the token-verification code path in the same binary — not a phantom third deployable. 62 2. **Missing KMS-access-credential row.** Accepted. Added row #7a with the same columns as every other row, rather than leaving it as a footnote inside #7. 63 3. **Env-var exception for `DB_DSN`.** Accepted — the objection is correct that "prefer, if 03 allows" was this track declining a call it owns. Upgraded to a requirement: a `DB_DSN_FILE` convention, with the env var as a transitional fallback only. Carried into `handoff-03-auth.md` as an ask to 03. 64 4. **No RBAC on audit-log read access.** Accepted. Added a requirement under Kubernetes-specific requirements that Secret-read and audit-log-read are distinct grants. 65 66 --- 67 Model: sonnet (lead, ruling) / sonnet (Ingrid Solano, objection turn). Turns consumed: 1 (single objection-turn pattern, per `CASTING.md` §5). 68
{
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/ux-notes.md"
}1 # UX Notes — Two Surfaces on Top of This Backend 2 3 Prepared for the `01-product-industry-research-design` track. See `../CLAUDE.md` for the assignment and `./CLAUDE.md` for this track's scope: this is light-touch, backend-supporting narrative, not a design deliverable in its own right — no wireframes, no design canvas. Text, one page, describing screens and the one design rule each surface enforces. 4 5 ## Surface 1: the admin profile-search console (Persona 2) 6 7 **Who uses it:** the support/ops analyst from `personas-use-cases.md`, resolving a ticket or investigating a flagged transaction. 8 9 **Screens:** 10 11 - **Search screen.** A single form: name (with a partial-match toggle — phone is always exact match, no toggle needed, per the accepted product limitation in `personas-use-cases.md`), phone. No blank-query submit — the form requires at least one field filled before the search button is enabled, matching the "no unconstrained query" product requirement. 12 - **Results list.** Each row shows masked/partial PII by default (e.g., a partially redacted phone number, a name, no address) and an "expand" affordance per row, not a bulk-select checkbox anywhere on the screen. 13 - **Expanded record view.** Full PII for one record, reached only through the per-row expand action. This is a different screen state, not a hover-to-reveal or an inline toggle on the results list — the extra navigation step is deliberate and consistent with the backend treating expanded access as a distinct, auditable event. 14 15 **The one design rule this surface enforces:** masked-by-default, expand-is-a-distinct-action. Every other screen decision — no bulk-select, expand requiring its own navigation step, no "export" button anywhere — is downstream of that one rule, not a separate design choice each time. 16 17 ## Surface 2: the onboarding pre-fill flow (Persona 3) 18 19 **Who uses it:** a new user signing up for a service that offers "sign in with a partner provider" instead of a blank registration form, per Persona 3 in `personas-use-cases.md`. 20 21 **Screens:** 22 23 - **Provider choice.** A short list of partner providers (ABC, XYZ, ...), each a single button — the user picks one, consistent with the connector being provider-agnostic on the backend. 24 - **Partner credential entry.** Username/password fields, framed as "sign in to `<provider>`," not the service's own login form — visually distinct, to signal whose credential this is. 25 - **Pre-filled review screen.** Name, phone, and address arrive already filled in from the `/identity` response; the user reviews and confirms rather than retyping. Fields are editable, not read-only — the provider is a starting point, not an irrevocable source of truth for this signup. 26 27 **The one design rule this surface enforces:** the partner's password is never visible to, or stored by, this service past the token exchange — the review screen never displays a password field, partner or otherwise, and nothing on this screen persists the credential entered on the previous one. That is the UI-level expression of the connector's `/auth` → `/identity` two-step: a short-lived token stands in for the password everywhere past the first screen. That token is held only by this onboarding flow, only for the span between the two calls, and is never persisted past this signup step — the connector that issues it holds it no longer than that either (`../02-ai-security-architecture/connector-security.md` §1). 28
{
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/api-connector-design-rationale.md"
}1 # API & Connector Design Rationale
2
3 Prepared for the `01-product-industry-research-design` track. See `../CLAUDE.md` for the full assignment text and `./CLAUDE.md` for this track's scope. Written so `../03-engineering-delivery/CLAUDE.md` can implement against it directly, without re-deriving the reasoning — that's this document's explicit job per the track brief. Builds on `industry-framing.md` and `personas-use-cases.md` in this directory; read those for the "why," this document is the "so build it this way."
4
5 **AI tooling note:** produced by Claude Code (Sonnet 5), track-owner subagent persona Naomi Voss, as a design-reasoning pass — no new external research; synthesizes the assignment text plus this track's prior work.
6
7 This document covers three surfaces in the order the assignment presents them: the `user_profile`/`user_credential` data contract (Q1), the search/retrieve REST API (Q2), and the third-party IDP connector (Q3).
8
9 ## 1. Data contract: `user_profile` and `user_credential`
10
11 **Why two separate entities, not one row:** the assignment names them separately, and that separation is doing real work, not just organizing fields. `user_profile` is PII a service holds *about* a person (name, address, phone) — data with governance obligations (retention, right-to-deletion, breach-notification exposure) attached to `../05-data-ops/CLAUDE.md`'s scope. `user_credential` is a proof-of-identity mechanism the service uses *to authenticate* that person — a username, an auth `method`, and (for the password method) a secret. Conflating them into one table would mean every profile read touches the credential store and vice versa, which is exactly the wrong blast radius for a credential breach.
12
13 **Why `user_credential.method` matters as a design decision, not just a column:** per `industry-framing.md` §2, this field is the seam where the assignment's deliberately simple password baseline becomes extensible toward what LoginID actually sells (FIDO2/passkey, `method = "webauthn"`) without changing the `method` discriminator itself. Concretely: `method` should be treated as a discriminator that governs which other fields on the row are meaningful — a `"password"` row has a password hash; a future `"webauthn"` row would have a public key and credential ID instead, arriving as new method-specific columns or a side table added by migration, not a password field left null by convention (public-key material does not belong in a password-hash column at all — see `../02-ai-security-architecture/threat-model.md`). Whether that's modeled as one wide table with nullable method-specific columns or a base table plus method-specific side tables is an implementation choice for `../03-engineering-delivery/CLAUDE.md` and `../05-data-ops/CLAUDE.md` — the product requirement this document is asserting is only that **`method` must be a real discriminator with room to grow, not a fixed enum assumed to always be `"password"`.**
14
15 **One relationship, not two independent tables:** a `user_profile` and its `user_credential`(s) need a stable foreign key (one profile can plausibly have more than one credential — a password today, a passkey added later, without replacing the old one until migration is complete). Designing for 1-to-many from the start avoids a breaking migration the day a second auth method is added.
16
17 ## 2. REST API: search and retrieve
18
19 **Endpoint shape, from the personas in `personas-use-cases.md`:**
20
21 - `GET /profiles/{id}` — exact retrieval by primary key. Serves any caller who already has the ID (the common, low-risk case).
22 - `POST /profiles/search` — the search surface Persona 2 (support/ops analyst — the internal user who looks up a customer record by name or phone during a support ticket; see `personas-use-cases.md`) actually uses day to day. **Resolved as `POST`, not `GET` with query params**, for two product-level reasons, not just implementation convenience: (1) the search body needs to carry partial-match flags and an explicit `expand` toggle — a boolean field in the request body, see the Contract Summary below for its exact shape — alongside the query fields, which is awkward to express and easy to get wrong as bare query params; (2) `GET` query strings land in access logs and browser history by default, and a phone number or partial name is PII — `POST` keeps the query payload out of the URL and lines up with where this document already requires every search call to be an audit-log event. This is the endpoint that needs the most deliberate authz design, because "search by phone" is simultaneously the legitimate support workflow and the shape of a scraping attack.
23
24 **Why search and retrieve are named as separate concerns in the assignment, and should stay separate in the API design:** retrieval-by-ID is nearly risk-free — if a caller has a valid ID, they likely already have a legitimate reason to look at that record. Search is different: it's a fuzzy-match query over a PII store, which means the API surface itself is the control point. Design implications this document asserts (mechanics belong to `../02-ai-security-architecture/CLAUDE.md`):
25
26 - Search results should default to **masked/partial PII** in the response, with full-field detail requiring either a distinct, more tightly scoped call or an explicit "expand" parameter — so that a broad search doesn't silently become a bulk PII export.
27 - Search should be **rate-limited and paginated** independent of retrieval, because unbounded search is the difference between "an analyst looked up one ticket" and "someone enumerated the table."
28 - Every search call is a natural audit-log event (who searched, for what, when) even before considering who's authorized to call it at all — this is a data-governance requirement (`../05-data-ops/CLAUDE.md`) that the API design needs to leave room for (e.g., not designing a stateless-only API that makes call attribution an afterthought).
29
30 **Why "plus security API authentication" is in the same assignment bullet as search/retrieve, not a separate question:** the assignment is signaling that the two are inseparable — a search/retrieve API over PII without an authentication design isn't a partial answer, it's a wrong one. This document asserts the product-level requirement (search needs scoped, attributable, rate-limited access, not just "logged in"); the actual authn/authz mechanism (API keys vs. OAuth2 client-credentials vs. mTLS, token formats, scope models) is `../02-ai-security-architecture/CLAUDE.md`'s to design.
31
32 ## 3. Third-party IDP connector: `/auth` and `/identity`
33
34 The assignment specifies exact contracts for both endpoints (see root `CLAUDE.md` — `/auth` takes `{"username", "password"}` and returns an `access_token`; `/identity` takes `{"phone", "name"}` and returns PII including a structured address). The design rationale here is about *why* that shape makes sense and *how to build the connector so it isn't hostage to any one provider's quirks* — not about re-specifying the contract, which is already fixed.
35
36 **Why the two-call shape (`/auth` then `/identity`) is the right pattern, per Persona 3** (the onboarding flow that pre-fills a new user's profile from a third-party IDP at signup; see `personas-use-cases.md`)**:** it's a minimal, legacy-style token-exchange flow — trade a long-lived credential (username/password) for a short-lived, purpose-scoped token, then use that token for the actual data pull. This is structurally the same shape as OAuth2's **resource-owner-password-credentials (ROPC) grant** — one of several standard ways an OAuth2 client obtains a token, this one taking a raw username/password directly instead of redirecting the user to log in elsewhere — and it is the specific grant type RFC 9700 now states MUST NOT be used, in favor of authorization-code or client-credentials flows (see `research-oauth-history.md` for the OAuth lineage this pattern sits in). Recognizing that lineage matters for the write-up: this connector contract is not a novel design, it's a well-understood, industry-standard-but-dated pattern (see `industry-framing.md` §2, revised after the S2 debate on `PLAN.md` — see `decisions/password-baseline-debate.md` — this is named as the conventional baseline, not as a claim about the assignment author's intent).
37
38 **Why the connector must be built provider-agnostic (ABC/XYZ as instances of one interface, not two bespoke integrations):** the assignment names the providers generically on purpose — this is a request to design *a connector interface*, with ABC and XYZ as its first two implementations, not to hardcode either one. Concretely, that means:
39
40 - `Authenticate(username, password) → token` and `FetchIdentity(token, phone, name) → PII` are **the vendor adapter's internal interface, not the connector's exported operation** — per `../02-ai-security-architecture/connector-security.md`, the token never crosses the adapter boundary. The adapter's one exported method is a single identity-lookup call (e.g. `LookupIdentity(username, password, phone, name) → PII`) that performs auth-then-fetch internally; the calling code (the onboarding flow from Persona 3, above) never sees, holds, or passes a token between two calls of its own.
41 - Each provider adapter owns its own quirks (token expiry format, field-name differences, rate limits, error codes) behind that common interface — the calling code never branches on which provider it's talking to, and never touches the intermediate token either way.
42 - The interface's shape should anticipate that a future provider might not be username/password-based at all — an OAuth2/OIDC provider would satisfy `Authenticate` differently (redirect-based auth-code exchange instead of a direct password POST) but still ultimately produce a token this connector can use the same way for `FetchIdentity`. This is the connector-side equivalent of `user_credential.method`'s extensibility from §1 — flagged here, not built here, per `industry-framing.md`'s point about not burying the actual assignment under speculative scope.
43
44 **Field-mapping note (product-level, not implementation):** the assignment's `/identity` response splits address into `street_address`, `locality`, `region`, `postal_code`, `country` — this is recognizably the OpenID Connect `address` claim shape (a widely-used standard structure for exactly this purpose), which is a useful fact for the write-up: it signals the assignment's own contract is already leaning on real industry convention rather than an arbitrary shape, which is worth naming explicitly as evidence of "this connector is meant to look like a real federated-identity integration," reinforcing the design choices above.
45
46 ## Summary for the engineering track
47
48 Three seams to build with deliberately, per this document: `user_credential.method` as a real discriminator (not a fixed enum), a search API designed around masked-by-default results + rate limiting + audit logging (not just an authenticated GET), and a provider-agnostic connector interface with ABC/XYZ as its first two adapters (not two copy-pasted integrations). Each seam is where this "before" baseline visibly points toward the "after" — passkeys, scoped search, and modern federated-identity providers — without this submission having to build any of the "after" itself.
49
50 ## Contract summary for 03
51
52 This section is the single-page version 03 should build against without re-reading the rest of this document. Where this section and the prose above conflict, this section is current.
53
54 **Entities (Q1)** — exact assignment field names:
55
56 - `user_profile`: `name`, `address`, `phone` (plus a primary key and the FK target for `user_credential`).
57 - `user_credential`: `username`, `method`, `password` (plus a primary key and a FK to `user_profile`).
58 - Relationship: **one `user_profile` to many `user_credential`** — do not model as 1:1. `method` is a real discriminator column (string/enum-with-room, not a fixed two-value enum) that determines which other columns on a `user_credential` row are meaningful; only the `"password"` method needs a password hash today. How that's physically modeled (nullable columns vs. method-specific side tables) is 03's and `../05-data-ops/CLAUDE.md`'s call, not fixed here.
59
60 **REST API (Q2):**
61
62 - `GET /profiles/{id}` — exact retrieval.
63 - `POST /profiles/search` — search, **not** `GET` with query params (rationale above). Request body shape: a top-level `expand: bool` field, sitting alongside the query fields (`name`, `phone`, partial-match flags), e.g. `{"name": "...", "phone": "...", "expand": false}` — not nested under a sub-object. Default (`expand` absent or `false`) returns masked/partial PII; `expand: true` requests full-field detail and, per the authz mechanism in `../02-ai-security-architecture/CLAUDE.md`, should require a more tightly-scoped credential than the base search call.
64 - Default response for search is masked/partial PII; `expand: true` is required for full-field detail and should be more tightly scoped than the base search call (same field, same meaning, as above).
65 - Search is rate-limited and paginated, independently of retrieval-by-ID.
66 - Every search call must be attributable and audit-logged (caller identity, query, timestamp) — the API shape must not make call attribution an afterthought (e.g., no anonymous/stateless-only search path).
67 - Authn/authz mechanism (API key vs. OAuth2 client-credentials vs. mTLS, token formats, scopes) is fully specified in `../02-ai-security-architecture/CLAUDE.md` — 03 implements against that document's design, not against an invention of its own.
68
69 **Connector interface (Q3):**
70
71 - Endpoints, methods, and JSON bodies for `/auth` and `/identity` are fixed by the assignment (see root `CLAUDE.md`) — 03 does not re-specify them.
72 - Build one provider-agnostic interface with ABC and XYZ as its first two adapters. `Authenticate` and `FetchIdentity` are internal to each adapter, not exported — the adapter's one exported method (e.g. `LookupIdentity(username, password, phone, name) → PII`) performs auth-then-fetch inside itself, so the vendor token never crosses the adapter boundary, per `../02-ai-security-architecture/connector-security.md`. The calling code (onboarding flow) never branches on provider identity and never holds a token itself.
73 - `/identity` PII response fields (`name`, `phone`, `street_address`, `locality`, `region`, `postal_code`, `country`) match the assignment exactly; the address sub-shape matches the OIDC Core `address` claim (`research-oidc-address-claim.md`) — reuse those member names verbatim in Go structs, do not rename them.
74 - Leave the interface's `Authenticate` signature able to accommodate a future non-password-based provider (e.g., OAuth2/OIDC redirect flow) without redesigning `FetchIdentity` — flagged as a design constraint on the interface shape, not something to build now.
75
76 **Out of scope for 03 — do not re-derive, read the owning track instead:**
77
78 - The authn/authz *mechanism* for the search/retrieve API — `../02-ai-security-architecture/CLAUDE.md`.
79 - How `user_credential.method`'s discriminator is physically modeled (columns vs. side tables) and all schema/migration decisions — `../05-data-ops/CLAUDE.md`.
80 - The `/auth` and `/identity` field names and JSON shapes — fixed by the assignment text itself, not a design decision any track owns.
81 - Whether the framing narrative calls the password baseline "deliberate" — it does not, per the S2 structured written debate recorded in `decisions/password-baseline-debate.md` ("S2" = the second story in `PLAN.md`'s task list, not a spec term); 03 need not engage with that question at all, it only affects prose in this track's docs.
82
83 {
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/containerization-design.md"
}1 # Containerization Design
2
3 Design-only. No Dockerfile, manifest, or config file is being committed yet — per root `CLAUDE.md`, code/config artifacts wait for Warren's project-wide go-ahead. This document describes what would be built, in enough detail that building it is a transcription exercise, not a design exercise.
4
5 Inputs this depends on: `../PLANNING.md` Service boundaries (03, stable), `../02-ai-security-architecture/handoff-04-secrets.md` v2 (02, stable), `../05-data-ops/migration-approach.md` (05, **final**).
6
7 Deployment target: the real Kubernetes dev/lab cluster on Warren's LAN, operated by amber-kubernetes (an external agent, not contacted during this take-home — see `./PLAN.md`). Manifests below are sketched against that cluster's shape, not a generic "some Kubernetes somewhere."
8
9 ## 1. Two images, not one
10
11 Mirrors 03's two-binary split: `cmd/api-service` and `cmd/idp-connector` are two separate images with independent Dockerfiles, tags, and Deployments. Reasons this isn't one image with two entrypoints: different threat models (per 02 — `api-service` is client-facing with bearer-token auth, `idp-connector` is outbound-only to vendors), different scaling needs, and a vulnerability in one image's dependency tree doesn't force a rebuild/redeploy of the other.
12
13 ## 2. Dockerfile shape (both images, same pattern)
14
15 Multi-stage Go build, three stages:
16
17 1. **`builder`** — `golang:1.2x-alpine` (or `-bookworm` if cgo is needed by a driver), `go mod download` cached as its own layer before `COPY . .`, then `CGO_ENABLED=0 go build -o /out/<binary> ./cmd/<binary>`. Static binary, no libc dependency to drag into the final image.
18 2. **`(none)`** — no separate test stage in the image build itself; tests run in CI (see `./ci-pipeline.md`), not as a Docker build step. A test failure should fail the pipeline stage, not silently produce a passing image build with a skipped test.
19 3. **`final`** — `gcr.io/distroless/static` (or `scratch` if no CA certs are needed — `idp-connector` needs them for outbound TLS to vendors, `api-service` doesn't unless its DB driver requires them). Non-root user (distroless's default `nonroot:nonroot`, UID 65532). Only the compiled binary and, for `idp-connector`, `/etc/ssl/certs` get copied in. No shell, no package manager, nothing an attacker can `exec` into even with a foothold.
20
21 Both images run as a read-only root filesystem (`securityContext.readOnlyRootFilesystem: true` at the Pod spec level) — neither binary writes to disk at runtime; migrations are a separate job (see §4), not something either service does to itself on boot.
22
23 ## 3. Deployment/Service manifest sketch — `api-service`
24
25 ```yaml
26 apiVersion: apps/v1
27 kind: Deployment
28 metadata:
29 name: api-service
30 namespace: loginid-poc # amber-kubernetes' convention, confirmed at apply time — not queried during this take-home
31 spec:
32 replicas: 2
33 selector:
34 matchLabels: {app: api-service}
35 template:
36 metadata:
37 labels: {app: api-service}
38 spec:
39 serviceAccountName: api-service # this SA mounts nothing from the signing-key row — see the issuer Deployment below
40 securityContext:
41 runAsNonRoot: true
42 readOnlyRootFilesystem: true
43 containers:
44 - name: api-service
45 image: registry.internal/loginid-poc/api-service:<tag>
46 resources:
47 requests: {cpu: "100m", memory: "128Mi"}
48 limits: {cpu: "500m", memory: "256Mi"}
49 envFrom:
50 - configMapRef: {name: api-service-config} # DB_DRIVER, HTTP_ADDR, AUTH_JWT_ISSUER/AUDIENCE — non-secret
51 env:
52 - {name: DB_DSN_FILE, value: /secrets/db/dsn}
53 volumeMounts:
54 - {name: db-dsn, mountPath: /secrets/db, readOnly: true}
55 readinessProbe: {httpGet: {path: /healthz, port: 8080}, initialDelaySeconds: 5}
56 livenessProbe: {httpGet: {path: /healthz, port: 8080}, initialDelaySeconds: 10}
57 volumes:
58 - name: db-dsn
59 secret: {secretName: api-service-db-dsn}
60 ---
61 apiVersion: v1
62 kind: Service
63 metadata: {name: api-service}
64 spec:
65 selector: {app: api-service}
66 ports: [{port: 443, targetPort: 8080}]
67 ---
68 apiVersion: apps/v1
69 kind: Deployment
70 metadata:
71 name: api-service-issuer
72 namespace: loginid-poc
73 spec:
74 replicas: 2
75 selector:
76 matchLabels: {app: api-service-issuer}
77 template:
78 metadata:
79 labels: {app: api-service-issuer}
80 spec:
81 serviceAccountName: api-service-issuer # the ONLY SA that mounts the JWT signing key, per handoff-04-secrets.md row #2 / F13's fix
82 securityContext:
83 runAsNonRoot: true
84 readOnlyRootFilesystem: true
85 containers:
86 - name: api-service
87 image: registry.internal/loginid-poc/api-service:<tag> # same image as api-service — mode selected below, not a separate build
88 resources:
89 requests: {cpu: "100m", memory: "128Mi"}
90 limits: {cpu: "500m", memory: "256Mi"}
91 envFrom:
92 - configMapRef: {name: api-service-config}
93 env:
94 - {name: DB_DSN_FILE, value: /secrets/db/dsn}
95 - {name: APP_MODE, value: issuer} # 02's resolution to F13: same image as the verifying Deployment, a second Deployment/SA, selected by this env var — not a third binary, not a flag (the config surface permits none)
96 volumeMounts:
97 - {name: db-dsn, mountPath: /secrets/db, readOnly: true}
98 - {name: jwt-signing-key, mountPath: /secrets/jwt, readOnly: true}
99 readinessProbe: {httpGet: {path: /healthz, port: 8080}, initialDelaySeconds: 5}
100 livenessProbe: {httpGet: {path: /healthz, port: 8080}, initialDelaySeconds: 10}
101 volumes:
102 - name: db-dsn
103 secret: {secretName: api-service-db-dsn}
104 - name: jwt-signing-key
105 secret: {secretName: api-service-jwt-signing-key}
106 ```
107
108 **Why two Deployments of one image, not RBAC on one:** a `Role` restricting `get` on the signing-key `Secret` does nothing once that `Secret` is already volume-mounted into a container — every replica of that Deployment can read the file regardless of who's allowed to `kubectl get` it. The only way to keep the verification code path (every `api-service` replica) from being able to read the private key is to never mount it there at all. `APP_MODE=issuer` on the second Deployment is 03's addition to the config surface (env-var only, no flags, per `PLANNING.md`); the same binary handles both modes, wired by that variable, not two separate images. This was F13 in the consistency pass — the earlier version of this manifest mounted the key into `api-service` directly, which the RBAC comment here used to (incorrectly) imply was sufficient.
109
110 `idp-connector`'s manifest is the same shape with its own two `Secret` volume mounts (per-vendor client credentials, one `Secret` per vendor per handoff row #5), its own `resources` block (`idp-connector` is I/O-bound waiting on vendor calls, not CPU-heavy — requests `{cpu: 50m, memory: 64Mi}`, limits `{cpu: 250m, memory: 128Mi}`), and no inbound `Service` — it is called by `api-service` or invoked as a job, never exposed to anything outside the cluster. Exact invocation shape (long-running service vs. on-demand job) is undecided; either way the container/image design above doesn't change.
111
112 **On process vs. host isolation (per 03/Renata, `../PLANNING.md`):** the two-binary split is a process boundary, not a placement guarantee — the `resources.limits` above are what actually enforce it if both Pods land on the same node, on top of (not instead of) 03's own connector-side backpressure (`context.WithTimeout`, bounded retry/backoff, their S4).
113
114 ## 4. Migration job
115
116 Per 05's requirement R1/R2 (`../05-data-ops/migration-approach.md`, **final**): a `Job`, not an init container, with `parallelism: 1` and `backoffLimit` set so it doesn't retry indefinitely against a database it's already partially migrated. Runs `goose` against the migration-runner credential (DDL-only, distinct from the runtime service credential per R4 — and, on PostgreSQL specifically, needing `CREATE EXTENSION` rights for `pg_trgm` that the runtime credential must never hold, per `./secrets-delivery.md`) before the Deployment's rollout proceeds — enforced by a CI/CD pipeline gate (`./ci-pipeline.md` §4), not by Kubernetes ordering alone, because R2's single-runner requirement has to hold across concurrent deploys too, not just within one.
117
118 R2's mechanism is orchestration-layer exclusivity (a concurrency-gated pipeline stage, `./ci-pipeline.md` §4), not a database lock — final reasoning: a transaction-scoped lock (`pg_advisory_xact_lock`, CockroachDB-compatible) releases between `goose`'s per-file transactions, so a second runner could interleave mid-run even with one held; a session-scoped lock would span the whole run but CockroachDB doesn't support that scope. No single database mechanism covers both the right scope and the right engine, so the exclusivity has to live outside the database entirely, on every backend, not just CockroachDB. 05 also names a cheap backstop — a unique constraint on `goose`'s version-table `version_id` column, added in the first migration regardless of whether goose's default schema already has one — so a second runner that does slip through fails loudly on insert instead of interleaving silently. That's a migration-file change (03/05's to make), not something 04 builds, but it's worth naming here since it's the backstop behind this job's own exclusivity design, not a replacement for it.
119
120 **Fail-closed schema check (R3, final):** the readiness probe below isn't just a liveness ping — `api-service` must refuse to serve if either of goose's two version tables (`shared/` and its backend-specific directory, per `migration-approach.md` §2's two-invocation mechanism) is behind the version the binary was built against. A check against only one table can pass while the other is stale — e.g. `shared/` current but the Postgres-only `pg_trgm` index migration unapplied — which is a passing check that proves nothing. `/healthz` above is liveness only; a separate `/readyz` (or the same endpoint gated by both checks) is where this lives, wired to whatever version-embedding mechanism 03 picks (build-time constant vs. reading the shipped migration files) — the check comparing *both* tables is 04's requirement to build against; the embedding mechanism is 03's call.
121
122 ## 5. NetworkPolicy: adopted, not cut
123
124 Considered and dropped from an earlier pass as scope creep, then reconsidered: §3's claim that `idp-connector` is isolated because it never gets an inbound `Service` is a paper boundary without one. Anything else on the namespace's default-allow network can still reach it directly and skip `api-service`'s auth path entirely — which undermines the exact "different threat models" reasoning 02 gave for splitting the images in the first place. A minimal default-deny `NetworkPolicy` for the `idp-connector` Pod, with one explicit `ingress` rule allowing traffic only from `api-service`'s pod selector, is ~15 lines and one more manifest to keep in sync — cheap relative to what it closes. Included below; everything else in this section stays cut.
125
126 ```yaml
127 apiVersion: networking.k8s.io/v1
128 kind: NetworkPolicy
129 metadata: {name: idp-connector-default-deny}
130 spec:
131 podSelector: {matchLabels: {app: idp-connector}}
132 policyTypes: [Ingress]
133 ingress:
134 - from: [{podSelector: {matchLabels: {app: api-service}}}]
135 ```
136
137 ## 6. What's deliberately not here
138
139 No service mesh, no Istio/Linkerd sidecar, no HorizontalPodAutoscaler, no PodDisruptionBudget. This is a two-service take-home submission, not a platform. If any of these earn their place later, they're additive to this design, not a rewrite of it.
140
141 ---
142 *AI tooling note: drafted directly by Theo (Sonnet, this session) from 03/02/05's hand-offs. Bree's and Callum's per-deliverable notes are pending — folded in per `./PLAN.md` §3 once their turns land, rather than as a separate decision artifact.*
143 {
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/local-dev-loop.md"
}1 # Local Dev-Loop
2
3 Design-only — a docker-compose sketch, not a committed `docker-compose.yml`. Local counterpart to the cluster target in `./containerization-design.md`, not a replacement for it: this is what a developer runs on a laptop; the cluster design is what the submission targets as the real deployment shape.
4
5 ## Two compose profiles, matching 03's `DB_DRIVER` env var
6
7 ```yaml
8 # docker-compose.yml (sketch)
9 services:
10 api-service:
11 build: {context: ., dockerfile: cmd/api-service/Dockerfile}
12 environment:
13 DB_DRIVER: ${DB_DRIVER:-sqlite}
14 DB_DSN_FILE: /run/secrets/db_dsn
15 HTTP_ADDR: ":8080"
16 ports: ["8080:8080"]
17 volumes: ["sqlite-data:/data"]
18 secrets: [db_dsn]
19 profiles: ["sqlite"]
20
21 api-service-pg:
22 extends: {service: api-service}
23 environment:
24 DB_DRIVER: postgres
25 depends_on: [postgres]
26 profiles: ["postgres"]
27
28 postgres:
29 image: postgres:16-alpine
30 environment:
31 POSTGRES_USER: dev
32 POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
33 ports: ["5432:5432"]
34 secrets: [postgres_password]
35 profiles: ["postgres"]
36
37 idp-connector:
38 build: {context: ., dockerfile: cmd/idp-connector/Dockerfile}
39 environment:
40 IDP_ABC_BASE_URL: ${IDP_ABC_BASE_URL:-http://mock-idp:9090}
41 secrets: [idp_abc_client_id, idp_abc_client_secret]
42 profiles: ["sqlite", "postgres"]
43
44 secrets:
45 db_dsn: {file: ./.dev-secrets/db_dsn} # untracked; developer creates once, e.g. "file:/data/dev.db" or a postgres:// DSN
46 postgres_password: {file: ./.dev-secrets/postgres_password}
47 idp_abc_client_id: {file: ./.dev-secrets/idp_abc_client_id}
48 idp_abc_client_secret: {file: ./.dev-secrets/idp_abc_client_secret}
49
50 volumes:
51 sqlite-data:
52 ```
53
54 `.dev-secrets/` is `.gitignore`d; a `.dev-secrets/README` (or a `make dev-secrets-init` target) writes the four files with placeholder dev values on first run. This isn't cosmetic — the inventory's rules apply to every environment, including dev: row 5 says vendor client credentials are "never in source or committed config", and row 1's `DB_DSN_FILE` is "Required, not preferred," with no development exemption written anywhere. Compose's file-based `secrets:` mechanism is the local equivalent of the `Secret` volume mounts in `./secrets-delivery.md` — same shape (a file path the process reads), just backed by a bind-mounted file instead of a Kubernetes object.
55
56 `docker compose --profile sqlite up` for the zero-dependency path (no Postgres container, fastest inner loop); `docker compose --profile postgres up` when a developer needs to exercise the Postgres-specific code path (the `pg_trgm` index, per `../05-data-ops/multi-db-strategy.md`). CockroachDB is omitted here for loop speed, not because it's redundant — the CockroachDB code path (`withRetry`'s engine-aware retry, `multi-db-strategy.md`) is genuinely different code from the Postgres path, which is exactly why `./ci-pipeline.md` runs a dedicated CockroachDB job; the local loop just doesn't need every backend on every save.
57
58 ## Dev credentials are dev credentials
59
60 The values developers put in `.dev-secrets/*` are local-only, never used outside a developer's own machine, and never the pattern used in `./containerization-design.md` or `./secrets-delivery.md` — those go through the mechanisms 02 specifies. Worth stating explicitly so nobody reads this file as the security design.
61
62 ## Migrations locally
63
64 `goose up` (or whatever 05 finalizes) run by hand or via a `make migrate` target against whichever profile is active — no orchestration-layer exclusivity needed locally since there's exactly one developer and one instance. The R2 requirement (`../05-data-ops/migration-approach.md`) is a production/CI concern; the local loop doesn't need to simulate it.
65
66 ## Hot-reload for `api-service`, dev-profile only
67
68 Considered cutting `air` as unneeded polish, reconsidered: `docker compose build && up` on every change is a slow inner loop for a project a developer iterates on repeatedly, and the cost of adding it is close to zero — one `air`-based dev target in the compose file, no production image impact since it only applies to the `sqlite`/`postgres` dev profiles, never `./containerization-design.md`'s images. Worth doing, not worth debating further.
69
70 ## What's deliberately not here
71
72 No local Kubernetes (`kind`/`minikube`) mirroring the lab cluster — compose is the right altitude for a two-service Go project's local loop; a local k8s cluster would be solving a problem this project doesn't have.
73
74 ---
75 *AI tooling note: drafted directly by Theo (Sonnet, this session) from 03's config surface and the containerization design above. Bree's (capability-case) and Callum's (scope-cut) per-deliverable notes reviewed and folded in inline above, per PLAN.md §3.*
76 {
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/secrets-delivery.md"
}1 # Secrets Delivery Mechanics 2 3 Design-only. This document implements the delivery mechanism for what `../02-ai-security-architecture/handoff-04-secrets.md` v2 specifies must be a secret and why — this track doesn't decide what's a secret, only how it reaches a running process. Row numbers below refer to that hand-off's inventory table. 4 5 ## Mechanism: Kubernetes-native `Secret` objects, file-mounted 6 7 Per the hand-off's Kubernetes-specific requirements: every secret in the inventory is delivered as a `Secret` resource, mounted read-only as a file into the consuming Pod, never passed as a plaintext env var except the one named transitional exception below. Manifests reference `Secret` **names** only (`secretName: api-service-db-dsn`) — values are never in a manifest, never in source, never in an image layer. Values are provisioned out-of-band by whoever applies the manifest (amber-kubernetes, per the deployment-target note) — this take-home describes that step, doesn't perform it. 8 9 | Row | Secret | Mount | Consuming ServiceAccount / RBAC | 10 |---|---|---|---| 11 | #1 | `DB_DSN` | File, read via `DB_DSN_FILE` (03's convention, per the hand-off's upgraded requirement). Env-var `DB_DSN` supported only as a fallback until 03 ships the `_FILE` convention. | `api-service` SA | 12 | #2 | JWT signing private key | File only, never env | `api-service-issuer` SA only — a second Deployment of the same `api-service` image, selected by `APP_MODE=issuer`. The verifying `api-service` Deployment's SA mounts nothing from this row (`./containerization-design.md`) | 13 | #3 | JWT public key / JWKS | Not a `Secret` — `ConfigMap` or fetched URL | n/a | 14 | #4 | API caller `client_secret`s | Not a Kubernetes `Secret` — lives in the authorization server's own datastore (hashed, Argon2id) | Datastore credentials are shaped like row #1, delivered the same way | 15 | #5 | `IDP_ABC_CLIENT_ID`/`SECRET` (+ XYZ pair) | File, one `Secret` per vendor so rotating one never touches the other | `idp-connector` SA | 16 | #6 | Vendor `access_token` | **Not deployed and not built** — per the S3 ruling, `idp-connector` does not cache vendor tokens; nothing to provision here | n/a | 17 | #7 | KEK (Key-Encrypting-Key) for envelope encryption — wraps a per-record Data-Encryption-Key (DEK) rather than encrypting data directly, so rotating the KEK means re-wrapping DEKs, not re-encrypting everything | File. Not a live requirement — provisioned only if/when TOTP (a supported `auth_method` value, not a committed implementation) actually materializes; the token-caching use case named in an earlier draft no longer applies, per row #6. | `api-service` SA, if/when TOTP lands | 18 | #8 | TLS serving private keys | `kubernetes.io/tls` `Secret`, automated issuance preferred (cert-manager or equivalent) | Ingress, or each binary if TLS terminates at the pod | 19 | #9 | End-user vendor password | Nothing to deliver — transient request-body data, never persisted | n/a | 20 | — | Migration-runner DB credential (05's R4, not in 02's inventory — flagged there as belonging to this surface) | File, same discipline as row #1. **Provisioning differs by backend:** on PostgreSQL specifically this credential needs `CREATE EXTENSION` rights for `pg_trgm` (the trigram index behind `user_profile.name` search) — usually an allow-listed extension on managed Postgres, otherwise elevated privilege; CockroachDB needs nothing extra (trigram indexing is native); SQLite has no privilege model at all. Distinct from the runtime service credential (DML-only, no DDL) per R4 — provisioning one shared credential for both would hand the running service the same extension-install rights the migration job needs, which it has no reason to hold. | Migration `Job`'s own ServiceAccount only | 21 22 ## RBAC discipline 23 24 No ServiceAccount `get`s or `list`s a `Secret` it doesn't mount — enforced by a `Role`/`RoleBinding` per service, scoped to exactly the `Secret` names in the table above for that service, not a blanket namespace-read grant. Row #2 is why this is a topology decision, not an RBAC one: a `Role` restricting `get` on a `Secret` does nothing once that `Secret` is volume-mounted into a container, so keeping the verification path from reading the signing key means never mounting it into `api-service`'s Deployment at all — hence the second `api-service-issuer` Deployment in `./containerization-design.md`, not a Role scoped "more narrowly" on the same Pod. 25 26 The audit/access-log read requirement (hand-off, Kubernetes-specific requirements, last bullet) is a **separate** RBAC grant from Secret access — see `./observability.md` for how that log stream is isolated. 27 28 ## Secrets-at-rest in etcd 29 30 Per the hand-off's explicit either/or: this design's answer is **enable encryption at rest for the `Secret` resource type** at the cluster level (an `EncryptionConfiguration` with a KMS or `aescbc` provider), rather than sourcing every value from an external store. Reasoning: an external store (Vault, cloud secrets manager) is real infrastructure this lab cluster doesn't currently run, and standing one up is out of scope for a take-home submission whose deployment is design-only anyway; cluster-level encryption-at-rest is a one-time `kube-apiserver` flag, not a new service to operate. If this became a real deployment with an existing Vault install, that would be the better answer — named here as the alternative, not designed. 31 32 ## CI-side secret handling 33 34 No secret in an image layer, build arg, or CI log — enforced by the pipeline's own gitleaks stage (`./ci-pipeline.md` §4) plus the container design never `COPY`ing anything but the compiled binary. CI's own credentials to reach the registry and (in the describe-only deploy stage) the cluster are themselves managed as CI-platform secrets (GitHub Actions encrypted secrets or equivalent) — same discipline, different delivery surface, not re-specified here since it's a standard CI-platform mechanism rather than something this design invents. 35 36 --- 37 *AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2. Bree's (capability-case) and Callum's (scope-cut) per-deliverable notes reviewed and folded in inline above, per PLAN.md §3.* 38
{
"file_path": "/Users/warrenthompson/Source/LoginID/PLANNING.md"
}1 # Planning Board 2 3 Thin cross-track coordination board — pointers and status only. The reasoning lives in each track's own files (`<track>/CLAUDE.md`, `<track>/PLAN.md`, deliverables), referenced by relative path. **One writer per row:** each lead edits only its own row; team-lead edits the header, the two Dana rows, and nothing else. Shape and rationale: `02-ai-security-architecture/planning-approach.md` §3. Org plan: `PLAN.md`. Hiring rules and registry: `CASTING.md`. 4 5 Status vocabulary: `not started` · `planning` · `plan drafted — awaiting hiring go` · `hiring go` · `hiring` · `debating` · `plan approved` · `blocked`. 6 7 | Track | Lead | Status | Blocking on | Blocked by | Planning notes | Hires (planned / registered / spawned) | Last updated | 8 |---|---|---|---|---|---|---|---| 9 | Orchestration (root) | Dana Whitfield | Phase 4 — Epics LT-1…LT-5; 01 (LT-6…LT-13) and 05 (LT-14…LT-20, LT-20 re-parented to 04) in Jira; 02/03/04 transcribe after the seam re-run; backlog not yet implementation-ready (see `PLAN.md` Phase 4 bar) | 02 pass fixes → seam re-run → 02/03/04 transcription → cross-track links | — | `PLAN.md`, `LOG.md`; Jira project `LT` | 18 / 18 (cap) | 2026-09-12 | 10 | Cross-track consistency pass (root, Phase 3) | Dana Whitfield | reported — 9 CONTRADICTION / 5 GAP / 52 findings; fixes applied by 01, 03, 04, 05; 02 (15 findings, incl. F3/F13/F-pag that gate 01/04/05 sentences) outstanding; seam re-run 1/4, 3, 5, 7, 9 next | 02 pass fixes; Oren's review of the F21 addendum | — | `decisions/cross-track-consistency.md` | — | 2026-09-12 | 11 | 01 Product & Industry Research (incl. Design) | Naomi Voss | plan approved — S1–S6 all done (claims audit, password-baseline debate, connector rationale hardened + cold read, search-authz requirements, UX notes, evaluator-legibility cold read) | — | — | `01-product-industry-research-design/PLAN.md`; `decisions/claims-audit.md`, `decisions/password-baseline-debate.md`, `decisions/rationale-cold-read.md` (S3+S6) all written; Newcomer loan from 04 (`wesley-okonkwo`) closed at 2/2-4 turns, both passed clean | 3 / 3 / 3 | 2026-09-12 | 12 | 02 AI Architecture & Security Architecture | Marcus Ilori | plan approved — 15 consistency-pass findings fixed (F3, F5, F6, F8, F10, F12, F13, F15, F34, F39, F40, F41, F42, F46, F50, F-pag), 2 new backlog stories added | — | — | `02-ai-security-architecture/PLAN.md`, `backlog.md` (13 stories S1–S13; S12/S13 added `from-consistency-pass` for F13's issuer-Deployment split and F3/F15's connector-credential/token-header wiring); `handoff-03-auth.md` now v3, `handoff-04-secrets.md` now v2, `connector-security.md`/`api-auth-design.md`/`threat-model.md`/`ai-workflow-narrative.md`/`decisions/second-review-security-docs.md`/`CASTING.md` §8 all fixed in place; two-party sentences sent to Naomi (F3), Theo (F13), Renata (F50), confirmed with Priya (F-pag) | 4 / 4 / 4 | 2026-09-12 | 13 | 03 Engineering & Delivery | Renata Cole | plan approved — all consistency-pass fixes closed, incl. F21 | team-lead transcription (Epic `LT-3`); Warren's implementation go-ahead | — | `03-engineering-delivery/PLAN.md`, `backlog.md` (11 stories S1–S11); `handoff-03-auth.md` v2 accepted; `decisions/error-semantics.md` + `decisions/context-propagation.md` reconciled against 05's contract and consistency-pass fixes (F7/F17/F19/F50); Oren's 2nd-pass review of 05's F21 addendum (batched `DeleteExpired`/`Drained`, asserted `DeleteProfile` reason) all 4 findings ruled and folded into S11; all other fixes applied: F10, F15, F23, F25, F44, F47, F52 | 4 / 4 / 4 | 2026-09-12 | 14 | 04 Infra & DevOps | Theo Bergman | plan approved — five deliverables drafted, Bree/Callum notes folded in, `backlog.md` written (Epic `LT-4`), consistency-pass fixes applied, including F12/F13 (issuer split-Deployment topology) | — | — | `04-infra-devops/PLAN.md`; `containerization-design.md`, `ci-pipeline.md`, `local-dev-loop.md`, `secrets-delivery.md`, `observability.md`, `backlog.md` | 3 / 3 / 3 | 2026-09-12 | 15 | 05 Data Ops | Priya Nandakumar | **plan approved**; backlog authored → Epic `LT-5`, transcribing | — | — | `05-data-ops/PLAN.md`; 6 stories S1–S6, S1 = Go-shaped contract in `multi-db-strategy.md` (critical path); S4 `migration-approach.md` + S5 retention table unblock 04; `migration-approach.md` drafted for 04 (R1–R5; single-runner requirement is platform-enforced — pg_advisory_lock is unavailable on CockroachDB), pending one objection turn; deep research authorized+run (lead): `05-data-ops/research-cockroachdb-postgres-semantics.md` — one Go package CAN serve Postgres+CockroachDB but needs an engine-aware retry/error seam (CRDB SERIALIZABLE returns client-retryable 40001; sentinel translation must be SQLSTATE-only); corrects R2 of `migration-approach.md`; S3 ruled `05-data-ops/decisions/multi-db-abstraction.md` — per-driver confirmed, but on reviewability not cost (only 2 real conditionals, doc's rejection reasoning corrected); codegen is orthogonal, 03's choice; **requirement on 03: one conformance suite run against every backend** — SQLite `LIKE` is case-insensitive vs Postgres case-sensitive, so the documented parity gap is a correctness gap, not a performance one; S2 ruled `05-data-ops/decisions/search-query-shape.md` — 8/8 objections accepted; adopted principle: **every caller-visible guarantee names an enforcement site (DDL constraint, index, normalization, or conformance test) — one with no site is a hope, not a guarantee**; **S1 Go-shaped contract stable in `05-data-ops/multi-db-strategy.md`** — 03 may start the DAO; S6 cold read (Saoirse, deliberately kept unfamiliar) returned 14 blocking items, all accepted and fixed: `Update`/`Upsert` are FULL REPLACES (nil NULLs the column), `Upsert` conflicts on id only (identity resolution is the connector's problem), error translation revised to structured codes + our own named constraints (SQLite has no SQLSTATE), `ErrInvalidArgument` added for CHECK violations, `is_active` enforcement site named; S5 closed in `05-data-ops/pii-governance.md` — retention windows (24mo direct / 30d cache / 7d orphan, all proposed pending product-legal), deletion mechanism, and the health check that matters: **alert on age-of-oldest-surviving-row per class, since a stopped sweep produces no error at all**; `deletion_log` added to the schema, PII-free by construction; `05-data-ops/backlog.md` authored — 7 stories (6 delivered, 1 open: derived-data deletion, owner TBD by the pass); keys written back by team-lead; S4 final and delivered to 04 (`migration-approach.md`) — objection turn overturned R2's *reasoning* while the requirement stood: a txn-scoped advisory lock releases between goose files, so it cannot cover a multi-file run; factory mismatch closed with 03 direct (two-party): `dao.Repository` composite exposing `Profiles()`/`Credentials()`/`Methods()`, `dao.New` signature unchanged — cited in S1 | 4 / 4 / 4 | 2026-09-12 | 16 17 ## Sessions (owned by team-lead) 18 19 Each lead is its own top-level Claude Code session in its own tmux window. Use the session name with `SendMessage` (confirm with `ListAgents`); hires are teammates of their lead's session and are reached through the lead. 20 21 | Role | Session name | tmux window | Model | 22 |---|---|---|---| 23 | PM / team-lead (Dana Whitfield) | `org-planning-loginid-takehome` | `1` | fable | 24 | 04 lead (Theo Bergman) | `04-infra-devops-47` | `04-infra` | sonnet | 25 | 02 lead (Marcus Ilori) | `02-ai-security-architecture-a2` | `02-security` | sonnet | 26 | 03 lead (Renata Cole) | `03-engineering-delivery-b7` | `03-engineering` | sonnet | 27 | 01 lead (Naomi Voss) | `01-product-industry-research-design-21` | `01-product` | sonnet | 28 | 05 lead (Priya Nandakumar) | `data-ops-planning-phase` (auto-renamed from `05-data-ops-a3`) | `05-data` | opus | 29 30 ## Service boundaries (owned by 03, read by 04) 31 32 Set by 03 during Phase 1 planning (full reasoning: `03-engineering-delivery/PLAN.md` §4). 33 34 - **Two binaries**, not one: `cmd/api-service` (Q1 DAO + Q2 REST API, client-facing, bearer-token auth) and `cmd/idp-connector` (Q3, outbound-only to vendors) — different threat models/auth surfaces per 02, independent scaling needs, mirrors the assignment's own Q2/Q3 split. 35 - **Package layout:** 36 ``` 37 cmd/api-service/main.go 38 cmd/idp-connector/main.go 39 internal/dao/ # Repository composite interface (Profiles/Credentials/Methods) + postgres/cockroachdb/sqlite implementations 40 internal/model/ # shared structs: UserProfile, UserCredential 41 internal/api/ # REST handlers, router, auth middleware 42 internal/connector/ # /auth, /identity client + handlers 43 internal/config/ # env-var config loading, shared by both binaries 44 internal/app/ # shared bootstrap (route registration, config wiring) both cmd/*/main.go wrap thinly 45 ``` 46 - **Config surface:** environment variables only (12-factor: config lives in the environment, not in checked-in files) — `DB_DRIVER` (postgres|cockroachdb|sqlite), `DB_DSN`, `DB_DSN_FILE` (file-mount path; takes precedence over `DB_DSN` when both are set, per 02's `handoff-04-secrets.md` S7 revision — a DSN's embedded password is as exposed by `kubectl describe`/crash dumps/env-in-child-process as any other secret in that inventory, so `DB_DSN` is now a transitional fallback, not a permanent equal option), `HTTP_ADDR`, `AUTH_JWT_ISSUER` = `https://auth.loginid-takehome.internal` / `AUTH_JWT_AUDIENCE` = `loginid-api-service` (literal values per `handoff-03-auth.md` — not secrets, safe to ship in config), `CONNECTOR_JWT_AUDIENCE` = `idp-connector-service` (the connector's own inbound audience, distinct from `AUTH_JWT_AUDIENCE` — `handoff-03-auth.md` v2 / `connector-security.md` §5, consistency-pass F15), `CONNECTOR_CLIENT_ID`/`CONNECTOR_CLIENT_SECRET` (api-service's own client credential for obtaining a `connector:identity-lookup`-scoped token to call idp-connector on a caller's behalf — a secret, added to `handoff-04-secrets.md`'s inventory as row 10 per F15), `IDP_ABC_BASE_URL`, `IDP_ABC_CLIENT_ID`/`IDP_ABC_CLIENT_SECRET`. No flags, no config files. 47 - **DAO driver-selection mechanism:** a factory `dao.New(driver, dsn string) (dao.Repository, error)` chosen at startup from `DB_DRIVER`. `dao.Repository` is a composite interface — `Profiles() ProfileRepository`, `Credentials() CredentialRepository`, `Methods() AuthMethodRepository`, plus `CreateProfileWithCredential(ctx, p, c)` and `Close() error` directly on the composite — matching 05's now-accepted contract in `multi-db-strategy.md` §1/§3b, kept deliberately distinct (not one flat interface) because it's a load-bearing PII/credential-separation control per `05-data-ops/pii-governance.md`: a handler has to reach for the credential accessor on purpose. Each backend package returns one struct satisfying the composite. Factory signature and `DB_DRIVER` selection are unchanged. 48 - **Stable for 04 to build against:** the two-binary split, the `cmd/` entrypoint names, the env-var config approach and variable names above, and the `dao.New(driver, dsn)` factory signature. **05's contract has landed and is accepted** (consistency-pass F23 — this line previously said the composite's method set was "not yet stable" pending the contract; it has since landed, including `CreateProfileWithCredential` and `Close()` above). **Not yet stable:** internal DAO implementation details, ORM/query-builder choice, handler-package internals — those are implementation, not contract. 49 - **Note for 04, from the Go-layout three-hats ruling (`03-engineering-delivery/decisions/go-layout-debate.md`):** "two binaries" is a *process* boundary, not a claim about host/deploy placement — whether `api-service` and `idp-connector` land on the same node is 04's call, not implied by this section. If they're colocated, `idp-connector`'s outbound-call failure isolation from `api-service` depends on 04's resource limits, not just the process split. Separately, S4 now requires `context.WithTimeout` on every outbound vendor call and a bounded connection pool/backoff on token-refresh retries — not a 04 concern, but flagged here so 04 doesn't assume connector-side backpressure is handled elsewhere. 50 51 ## Dependency order (from `planning-approach.md` §2) 52 53 1. First wave, in parallel: 01, 02, 05 — 05 is on the critical path (it hard-blocks 03's DAO and partially blocks 04). 54 2. 03 split start: scaffolding may begin immediately; DAO once 05's Go-shaped contract is stable; middleware once 02's receiver-shaped auth hand-off lands. 55 3. 04 last, gated on the three inputs named in its row. 56
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/multi-db-strategy.md",
"offset": 185,
"limit": 140
}185 Get(ctx context.Context, id string) (*AuthMethod, error)
186 GetByName(ctx context.Context, name string) (*AuthMethod, error)
187 List(ctx context.Context, activeOnly bool) ([]AuthMethod, error)
188 }
189
190 type ProfileQuery struct {
191 Name *string // case-folded substring match; caller input escaped by the DAO
192 Phone *string // EXACT match on the E.164-normalized value (not prefix)
193 Region *string // case-folded exact match
194 Country *string // exact match, alpha-2, upper-cased by the DAO
195 Limit int // 1..100; 0 means "use default 50", deliberately
196 Offset int // 0..10_000
197 }
198 ```
199
200 ### `Search` semantics carried over from the S2 ruling
201
202 These were settled in `decisions/search-query-shape.md` and were missing from the first draft of this contract — the intent-versus-text gap a cold read was run to find.
203
204 - **`total` is the count of ALL rows matching the filters, ignoring `Limit` and `Offset`** — not the number of rows on this page. Exact, not estimated, computed by a second `COUNT(*)` outside any wrapping transaction, so under concurrent writes it may disagree with the page by a row. **`total` is a magnitude, not a promise of reachability**: it can legitimately exceed what the `Offset` ceiling of 10 000 allows a caller to page to.
205 - **An all-nil `ProfileQuery` is legal** and returns every profile, paginated. `ErrInvalidQuery` covers an *empty* filter (a non-nil pointer to `""`), not the *absence* of filters. Whether a given caller may list all profiles is authorization policy owned by `../02-ai-security-architecture/`; the DAO does not invent one, and the pagination ceiling is protection rather than a policy veto.
206 - **Filters combine with AND.** No OR, no negation.
207 - **Searching for *absent* address values is out of scope.** `nil` means "don't filter" and `""` is an error, so no legal query reaches rows with a NULL `region`. Callers needing that list and filter client-side. This matters because partial addresses from `/identity` are the normal case for `idp_cache` rows.
208
209 ### What `Search` does on SQLite — read this before assuming parity
210
211 The first draft said nothing about this anywhere; the only trace was "Postgres + CockroachDB only" in the index table, which a reader cannot turn into behaviour.
212
213 SQLite runs **the same query with the same results in the same order** — `LOWER(name) LIKE ?` with explicit case folding and `COLLATE BINARY` ordering — but **without any index to accelerate it**, so it is a full table scan. Row sets and ordering are identical to Postgres for ASCII input; only speed differs. That identity is not an accident of the query text, it is asserted by the conformance suite in §7, which is the only thing that keeps it true as the code changes.
214
215 The one residual difference, accepted and named: Postgres `lower()` is locale-aware and SQLite's is ASCII-only, so **non-ASCII** case folding still diverges between backends. SQLite is the local/dev/demo backend, not a production peer.
216
217 `CredentialRepository` never accepts or returns a `UserProfile` field, in either direction, including as an internal convenience struct. That is §6.1.
218
219 **`Create` / `Update` / `Upsert` are three distinct methods, and this changes the earlier prose.** The previous version of this document described `Update` as "implemented as a portable upsert", which would mean `Update` on a missing row silently *creates* it. That is exactly the kind of surprise that produces a bug in a caller, so it is ruled out:
220
221 - `Create` — insert. `ErrAlreadyExists` if the ID is taken.
222 - `Update` — update an existing row by ID. **`ErrNotFound` if the row does not exist.** It never creates.
223 - `Upsert` — the portable `INSERT ... ON CONFLICT (id) DO UPDATE` form. Exists because the IDP hydration path genuinely does not know whether the row exists, which is a real caller rather than a hypothetical one. Any other caller should use `Create` or `Update`.
224
225 **`Update` and `Upsert` are FULL REPLACEMENTS, not partial patches.** This is the single most consequential thing in this document and it was missing from the first draft. Every column is written from the struct you pass. **A nil `Phone` sets the stored phone to NULL; it does not leave the existing value alone.** A caller wanting patch semantics must read, modify, and write back. Stated this loudly because both readings are reasonable and the wrong guess silently destroys data.
226
227 **`Upsert` conflicts on `id` only, and the caller must already hold that `id`.** It exists for re-hydrating a row already known to exist — not for resolving an unknown one. A cold read caught the circularity in the original justification: the IDP path "does not know whether the row exists" and therefore does not know its `id` either, so `Upsert` could not have served the caller it was justified with. **Resolving an `/identity` payload to an existing profile row is the connector's problem, not the DAO's** — there is deliberately no unique key on `phone` or `name` to match on, because neither is unique in reality. The connector decides identity and then calls `Create` or `Update`.
228
229 **`Delete` on a missing row returns `ErrNotFound`**, in both repositories — not a silent nil. Consistent with `Update`. Callers wanting idempotent delete check `errors.Is(err, ErrNotFound)` and treat it as success.
230
231 **`GetByUsername` lower-cases its input inside the DAO.** Callers pass whatever the user typed. This matches the `UNIQUE (lower(username))` index in §5; requiring callers to fold case themselves would put the guarantee outside its enforcement site.
232
233 `Context` is first on every method and cancellation is honoured. **No method holds a transaction across calls.** If the service layer needs a multi-statement transaction, that is a change to this contract — flag it to 05 rather than improvising a `Begin`/`Commit` pair inside 03.
234
235 **`Delete` on a profile cascades to its credentials — see §6.2 before calling it.** Pointer placed here deliberately: a cold read found the surprise arriving ~140 lines before the explanation.
236
237 ## 3b. The one cross-table operation: atomic registration
238
239 Added after 03's Detail Hawk review found a gap that was not yet a contradiction but would have become one. The contract bars 03 from composing its own multi-statement transaction across calls (§3), and contained no method writing more than one table — so the assignment's registration flow (create a profile, create its first credential) had **no atomic path at all**. Two independent calls leave a window in which a profile exists with PII and no credential; if the second call fails, that window never closes.
240
241 That orphan is the same failure class as the orphaned IDP cache row in `pii-governance.md`: a PII-bearing row nothing references, governed by a retention clock written for a different case. Discovering it during implementation would have meant either an orphan-producing registration flow or 03 improvising the transaction the contract forbids.
242
243 **Ruling: the DAO supplies the transaction, because the DAO is the only layer allowed to open one.**
244
245 - It lives on the **composite**, not on either sub-repository — it spans both tables, and putting it on `CredentialRepository` would breach §6.1 (that interface still never accepts or returns a `UserProfile`; the composite is a different type and the separation is intact).
246 - Both rows are written in one transaction, and on CockroachDB the whole transaction runs inside the retry seam (§4).
247 - IDs are DAO-generated for both, as everywhere else. `c.UserID` is ignored on input and set from the profile the DAO just created — a caller cannot supply a mismatched pair.
248 - Errors: any of `Credentials().Create`'s errors, plus `ErrInvalidArgument`. On any failure **neither row exists**.
249 - This is the *only* cross-table operation in the contract, and it stays that way. A second one is a change to this contract, not an implementation decision.
250
251 ## 3c. Retention and deletion — added after the consistency pass (F21)
252
253 `pii-governance.md` specifies a bulk retention sweep and a subject-deletion request sharing **the same code path**, each writing a `deletion_log` row, and §5 of this contract defines that table. **None of it was reachable from the interface.** There was no sweep method, no log method, `Profiles().Delete` wrote no log row, and §3 forbids 03 from opening a transaction to combine the two — so a policy this track ruled on could not be implemented against the contract this track wrote, and no track owned the gap. Found by the cross-track consistency pass, not by any of this track's four reviewers, because every one of them was scoped to a single document and the contradiction lived between two.
254
255 Both methods sit on the **composite**, for the same reason as `CreateProfileWithCredential`: they span `user_profile` and `deletion_log`, and the DAO is the only layer permitted to open a transaction.
256
257 ```go
258 type RetentionClass string // "direct" | "idp_cache" | "idp_cache_orphan"
259
260 type SweepResult struct {
261 RowsExamined int
262 RowsDeleted int
263 OldestSurvivingAt *time.Time // see "what nil means" below — NOT "nothing was old enough"
264 Drained bool // true when this class had no more deletable rows at the batch limit
265 }
266 ```
267
268 **What `OldestSurvivingAt` means, stated precisely because three conditions were collapsing into one nil.** It is the clock-column value of **the oldest row remaining in this class after this call** — every remaining row, not only rows that were candidates for deletion. It is `nil` **only when the class has zero rows remaining**, and never for any other reason. In particular it is *not* nil when nothing was old enough to delete: survivors exist, and their oldest age is exactly the number the health check wants (it will simply be younger than the window, which is the healthy case). Without this, "sweep is working and the data is young" and "sweep has stopped and I cannot tell" both returned nil, which would have defeated the field's only purpose.
269
270 **`OldestSurvivingAt` is meaningful only when `Drained` is true.** This falls out of batching and neither review raised it on its own: mid-sweep, deletable rows remain by construction, so the oldest survivor is old and the metric would fire spuriously on every batch but the last. 04 alerts on `OldestSurvivingAt` **only from a `Drained` result**; a non-drained batch reports progress, not health.
271
272 **`DeleteExpired(ctx, class, olderThan, maxRows)`** — one class per call, never a mixed-class predicate, because a single predicate spanning two clocks is how the wrong window gets applied to the wrong rows.
273
274 **Bounded by `maxRows`, and one transaction per batch — not one per sweep.** A class-wide delete with no chunking holds a single transaction over an unbounded row count, which is the failure mode 03 already identified for credential writes in their `context-propagation.md`. `maxRows` is required, must be in `1..10_000`, and returns `ErrInvalidArgument` outside it. The caller loops until `Drained` is true. Each batch is its own transaction, so an interrupted sweep leaves a consistent database with fewer rows deleted — never a half-written batch.
275
276 **`DeleteExpired` must not be called with a request-scoped context.** It is a background operation governed by the job's own timeout; a sweep cancelled halfway because an inbound HTTP request went away is a retention policy that silently depends on request lifetimes. Constructing that context is 03's layer, not the DAO's — stated here as a requirement on the caller, with its enforcement site named as 03's sweep-invocation code plus a conformance test asserting an already-cancelled context returns before deleting anything. The clock column is the class's own: `updated_at` for `direct` and `idp_cache`, **`created_at` for `idp_cache_orphan`** — a clock the read path touches would let an orphan reset its own expiry and never die. Each deleted profile cascades to its credentials and writes one `deletion_log` row with reason `retention_sweep`, in the same transaction as the delete.
277
278 `SweepResult` returns the three metrics `pii-governance.md` requires, and **`OldestSurvivingAt` is the load-bearing one**: it is the only signal that detects a silently stopped sweep, because rows-deleted cannot distinguish a broken sweep from a legitimately empty one — both report zero. Returning it from the DAO rather than leaving 04 to compute it is deliberate: the DAO is the only layer that can answer it in the same query plan as the sweep.
279
280 **`DeleteProfile(ctx, id, externalRef)`** — subject-deletion. Writes the `deletion_log` row in the same transaction, with **`reason` set internally to `subject_request`; it is not a parameter.**
281
282 That is a deliberate narrowing from the first draft, which took `reason` as a caller-supplied string. A free string lets a caller write `retention_sweep` onto a subject-deletion row by copy-paste, which silently destroys the one property the audit trail exists to have: that its reason codes are trustworthy. **The reason a row exists is a fact about which method was called, so the method should assert it — not the caller.** `DeleteExpired` writes `retention_sweep` the same way. The two reason codes in `ck_deletion_log_reason` are therefore exhaustive by construction rather than by convention, and a third value is a contract change that adds a method, not a new string a caller may pass.
283
284 **This supersedes `Profiles().Delete` for anything governed by retention.** `Profiles().Delete(ctx, id)` remains for ordinary application deletes and still writes **no** log row — deliberate, because a log of every incidental delete is not an audit trail, it is noise with PII-adjacent identifiers in it. If that distinction proves wrong in implementation, flag it rather than making `Delete` log.
285
286 **Neither method is a "third cross-table operation" loophole.** These two plus `CreateProfileWithCredential` are the complete set; a fourth is a change to this contract.
287
288 ## 3a. Per-method summary — read this row before writing that method
289
290 Added after a cold read observed that this document is organized by *artifact* (factory, types, interfaces, errors, schema) while an implementer works by *verb* — so answering "I am about to write `Create`, what do I need?" meant holding five sections open at once. Four of that read's fourteen blocking questions existed only because a method's full story was never assembled in one place. This table closes them by construction; the prose above remains the authority where they disagree.
291
292 | Method | Caller supplies `ID`? | Timestamps | Replace or patch | Can return |
293 |---|---|---|---|---|
294 | `Profiles().Get` | yes (lookup key) | — | — | `ErrNotFound` |
295 | `Profiles().Search` | — | — | — | `ErrInvalidQuery` |
296 | `Profiles().Create` | **no** — must be `""`, DAO generates | DAO sets both | — | `ErrInvalidArgument` (non-empty ID, or CHECK violation); `ErrAlreadyExists` — see note below |
297 | `Profiles().Update` | yes — identifies the row | DAO sets `UpdatedAt` | **full replace**; nil NULLs the column | `ErrNotFound`, `ErrInvalidArgument` |
298 | `Profiles().Upsert` | **yes** — conflict target; caller must already hold it | DAO sets `UpdatedAt`; sets `CreatedAt` on insert only — **the `DO UPDATE SET` list must exclude `created_at`** | **full replace** | `ErrInvalidArgument` |
299 | `Profiles().Delete` | yes | — | — | `ErrNotFound`; **cascades to credentials, see §6.2** |
300 | `Credentials().GetByUsername` | — | — | — | `ErrNotFound`; DAO lower-cases input |
301 | `Credentials().ListByUserID` | — | — | — | empty slice, not `ErrNotFound`, when the user has none |
302 | `Credentials().Create` | **no** — DAO generates | DAO sets both | — | `ErrDuplicateUsername`, `ErrInvalidMethod`, `ErrInvalidCredential`, `ErrInvalidArgument` |
303 | `Credentials().Update` | yes | DAO sets `UpdatedAt` | **full replace** | `ErrNotFound`, `ErrDuplicateUsername`, `ErrInvalidCredential` |
304 | `Credentials().Delete` | yes | — | — | `ErrNotFound`; does **not** delete the profile (§6.2) |
305 | `Methods().Get` / `GetByName` | yes / name | — | — | `ErrNotFound` |
306 | `Methods().List` | — | — | — | empty slice when none |
307
308 `DeleteExpired` and `DeleteProfile` (§3c) are not in the table above: both are composite-level, both write `deletion_log` in the same transaction as the delete with the reason code asserted by the method rather than the caller, `DeleteExpired` returns counts rather than rows and is batched by a required `maxRows`, and both return `ErrInvalidArgument` on a bad class or batch bound. `CreateProfileWithCredential` (§3b) is likewise not in it because it spans two rows: IDs DAO-generated for both, `c.UserID` ignored on input and set from the created profile, both timestamps DAO-set, and on failure neither row exists.
309
310 Every write method above additionally runs inside the retry seam described in §4 when the engine is CockroachDB.
311
312 **Three clarifications from 03's first-pass review, each resolving an ambiguity rather than changing a decision:**
313
314 1. **`ErrAlreadyExists` is real but not caller-reachable.** Because no caller supplies an ID, a primary-key collision can only arise from a UUID collision or a duplicated retry — never from caller error. It stays in the sentinel set and stays translated (it is one branch in the error mapping), but it is documented as an **internal-error signal, not a caller contract**, and the conformance suite is **not** required to exercise it. 03 should not spend effort constructing a test for a path a caller cannot reach.
315
316 2. **`Limit == 0` means "default 50", and requesting zero rows is out of scope.** The review correctly noted this is the same zero-value trap the pointer-means-absent convention avoids elsewhere in the struct. Accepted as a deliberate exception rather than fixed: a count-without-rows query is a *different operation*, and if it is ever wanted it arrives as an explicit `Count(ctx, ProfileQuery)` method — not as a magic value of `Limit`. `total` already gives a caller the count alongside the smallest useful page.
317
318 3. **`Upsert` must not overwrite `created_at`.** Its `ON CONFLICT (id) DO UPDATE SET` list excludes `created_at` explicitly. Without that, every IDP re-hydration would silently reset the creation timestamp — on a column `pii-governance.md` keys retention to. This was an omission that would have produced a silent, retention-affecting bug, and it is now stated at both the method table and here.
319
320 ## 4. Error semantics
321
322 ```go
323 var (
324 ErrNotFound = errors.New("dao: not found"){
"file_path": "/Users/warrenthompson/Source/LoginID/01-product-industry-research-design/decisions/f9-phone-search-ruling.md"
}1 # F9 — phone search: partial-match requirement vs. 05's exact-only contract
2
3 **Finding:** `decisions/cross-track-consistency.md` F9 — `personas-use-cases.md:41` required partial-match search on both `name` and `phone`; `05-data-ops/multi-db-strategy.md` accepted phone as exact-match-only on the E.164-normalized value (a deliberate S2/S6 ruling, enforced by the index definition and already accepted by 03). Owner: Naomi (01's requirement is the one that has to move or be justified against an already-settled contract).
4
5 **Resolved directly with Priya Nandakumar (05 lead), two-party, per the precedent in `02-ai-security-architecture/planning-approach.md` §2** (a bounded, two-track decision that must still land in a durable file, not live only in chat).
6
7 **Priya's offer:** phone prefix match (`+4477…`) is cheap — the existing B-tree index on the normalized value serves it natively — and she'd add it if the persona work genuinely needs it. Infix ("contains 8891 anywhere") is not cheap on any backend and she'd push back on it. Her recommendation: keep phone exact-only, scope the UX partial-match toggle to name only, and record the phone limitation as deliberate with its reasoning stated.
8
9 **Ruling:** accepted in full, no prefix match requested. Persona 2 is explicitly described as someone working from "a phone number a customer just read aloud" — that is the exact-match case, not the partial-match case; the partial-match need is real for names (people mishear and misspell names, not digits) and E.164 normalization already absorbs the formatting variation that looks like a partial-match need on phone (different punctuation/spacing normalizing to one value). No persona in this track's work describes an analyst genuinely working from a partial or remembered fragment of a phone number.
10
11 **Applied:**
12 - `personas-use-cases.md`'s search-authorization appendix (line 41 as originally flagged) now states phone as exact-after-normalization, with the reasoning inline and the enforcement site named (the B-tree index on the normalized `phone` column in `multi-db-strategy.md`).
13 - `ux-notes.md`'s search-screen description now scopes the partial-match toggle to name only.
14
15 **Enforcement site:** `05-data-ops/multi-db-strategy.md`'s B-tree index on the E.164-normalized `phone` column (05's file, already accepted by 03) — 01 makes no independent claim about how the match is implemented, only that exact-after-normalization is the correct product requirement.
16
17 ---
18 Model: sonnet (Priya Nandakumar, 05 lead, offer) / sonnet (Naomi Voss, 01 lead, ruling and edits applied). Turns consumed: 1 (direct two-party exchange, no hire turns — this is a lead-to-lead decision, not a pattern run).
19 {
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/decisions/error-semantics.md"
}1 # Decision: Error semantics (wrap vs. sentinel)
2
3 Pattern: rotating devil's advocate, decision 1 of 3 (seat: Oren Castellan). Proposer: Renata Cole.
4
5 ## Proposal
6
7 Go 1.13+ error wrapping (`fmt.Errorf("...: %w", err)`) for propagating unexpected/lower-level errors up the call stack, plus a small set of exported sentinel errors (`dao.ErrNotFound`, `dao.ErrDuplicate`) for conditions callers are expected to branch on via `errors.Is`.
8
9 ## Objections — Oren Castellan (Detail Hawk)
10
11 1. **Sentinel set must be closed and enumerated now**, not left open-ended — otherwise hires add ad hoc `ErrX` values across S2–S4 with no stable contract. Wants a fixed list plus a named owner and a propose-before-adding rule.
12 2. **Wrapped errors must never cross the HTTP boundary raw** — `err.Error()` on a wrapped DB error can leak driver text (DSN fragments, column/constraint names) to an API client. Wants an explicit translation function at the boundary; anything unmapped defaults to a generic 500 with no wrapped detail.
13 3. **`errors.Is` against a sentinel breaks silently if a backend returns a driver-native error instead** (e.g. `*pgconn.PgError` instead of `dao.ErrDuplicate`). Wants the `Repository` interface contract to state translation is *mandatory* per backend, verified by an S5 test case per backend.
14 4. **No stated rule for `context.DeadlineExceeded`/`context.Canceled`.** These aren't ordinary internal errors and shouldn't page as generic 500s. Wants them checked and mapped to a distinct response code before generic wrapping applies.
15
16 ## Ruling (Renata Cole)
17
18 All four accepted; no decline.
19
20 1. Sentinel set is closed and fixed now: `dao.ErrNotFound`, `dao.ErrDuplicate`, `dao.ErrInvalidInput`. Defined once in `internal/dao`. Rule: no hire adds a sentinel unilaterally — propose it to me with the one-line reason, I add it. This applies for the rest of the design/implementation phase.
21 2. `internal/api` never serializes `err.Error()` on an unmapped error. A translation function (`internal/api` boundary layer) maps the three sentinels to their HTTP status/safe message; anything else defaults to a generic 500 with no wrapped detail surfaced to the client. Wrapped errors are for logs only.
22 3. The `Repository` interface contract states translation of known driver-native errors to the shared sentinels is **mandatory**, not optional, per backend implementation. S5's test strategy gets an explicit line item: one test per backend proving the translation happens (e.g., a unique-constraint violation surfaces as `dao.ErrDuplicate`, not a raw driver type).
23 4. `context.DeadlineExceeded` and `context.Canceled` are checked explicitly and mapped to a distinct response (504 for deadline exceeded; treat client-cancel as a non-alerting outcome, not logged as a 500) before generic wrapping/translation applies.
24
25 ## Status
26
27 Ruled. Carries into S2 (DAO sentinel definitions + per-backend translation) and S3 (handler-layer translation function, context-error handling).
28
29 ## Superseded in part — 05's contract lands (2026-09-12)
30
31 05's `multi-db-strategy.md` §4 is the authoritative sentinel set and translation mechanism for the DAO layer; it supersedes the specific names and the translation rule this decision guessed at before the contract existed. What stands vs. what's replaced:
32
33 - **Replaced:** the invented sentinel set (`ErrNotFound`/`ErrDuplicate`/`ErrInvalidInput`) is superseded by 05's actual seven: `ErrNotFound`, `ErrAlreadyExists`, `ErrDuplicateUsername`, `ErrInvalidMethod`, `ErrInvalidQuery`, `ErrInvalidArgument`, `ErrInvalidCredential`. These are 05's to own and extend (own-and-propose-before-adding now routes through them, not me, for anything DAO-internal) — my closed-set/no-ad-hoc-additions principle still applies, just to their list.
34 - **Replaced:** "match SQLSTATE only" is superseded by 05's corrected rule — match structured error codes (SQLSTATE on Postgres/CockroachDB, extended result codes on SQLite) plus constraint names we define ourselves in DDL, never free-text messages. This is a more complete version of objection 2's concern (leaking driver text), not a contradiction of it.
35 - **Stands unchanged:** the HTTP-boundary rule (objection 2 — `internal/api` never serializes `err.Error()` on an unmapped error; wrapped errors are for logs only) — this is 03's layer, not 05's, and 05's contract doesn't touch it. The context-cancellation rule (objection 4) also stands unchanged, same reason.
36 - **Stands, now backed by a real mechanism instead of a guess:** objection 3's demand for mandatory per-backend translation — 05's contract §4 gives the actual mapping table (condition → PG/CRDB code → SQLite code → sentinel), and §7's conformance suite makes "sentinel parity for every error condition in §4" a stated, required test — stronger than what this decision asked for.
37
38 ## Consistency-pass fixes (2026-09-12)
39
40 **F7 — authn/authz failure mapping was unstated.** This decision covered DAO-sentinel and context-error mapping but never said what an authn failure (missing/invalid token) or an authz failure (scope check, or an `authorize()` policy denial) returns. Fixed: a missing or invalid token → `401`. A scope failure and an `authorize()` policy denial both → `403`, with an **identical client-visible response body** — the two are distinguished only in the audit log (`handoff-03-auth.md`'s "policy decision: allow/deny, and if deny, whether it was a scope failure or an authorize() denial" field), never in what the client sees. This closes the gap the consistency pass found in `handoff-03-auth.md:27,50`'s "distinct signals for detection" requirement.
41
42 **F50 — the boundary-sanitization rule needed to name driver `DETAIL` text explicitly.** The translation function (ruling item 2, above) already stated that unmapped errors default to a generic 500 with no wrapped detail — but a Postgres CHECK or unique-violation error's `DETAIL` line (which echoes the failing row's actual column values, including PII) is exactly the kind of "wrapped detail" that rule exists to stop, and it wasn't named specifically enough for a reader to be sure it was covered. Stated explicitly now: **the translation layer sanitizes every driver error down to its structured code plus our own constraint name before wrapping it for the log — the raw driver error (including any `DETAIL`/`Key (...)=(...)` text) is never wrapped, logged, or passed through in any form.** This translation layer (05's §4 mapping, implemented in `internal/dao`) is the enforcement site; `handoff-04-secrets.md`'s never-log list gets the matching entry from 02.
43
44 ---
45 Model: sonnet (Oren Castellan, objection seat; Renata Cole, lead, proposer + ruling). Turns consumed: 1 hire turn + lead synthesis.
46 {
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/decisions/context-propagation.md"
}1 # Decision: Context propagation into the DAO layer 2 3 Pattern: rotating devil's advocate, decision 2 of 3 (seat: Nolan Reyes). Proposer: Renata Cole. 4 5 ## Proposal 6 7 Every `Repository` method (and its three sub-interfaces — `Profiles`/`Credentials`/`Methods`) takes `context.Context` as its first argument, propagated unbroken from the HTTP request through the handler into every DB call, so request cancellation/timeout actually cancels in-flight queries. 8 9 ## Objections — Nolan Reyes (Veteran) 10 11 Agreed with the direction but pushed on three failure modes from production experience: 12 13 1. **Cancellation racing a multi-statement write.** A cancelled `SELECT` aborts cleanly; a cancelled multi-statement write (e.g., profile + credential saved together) risks a half-written row unless cancellation is scoped to a transaction, not a statement. Cited a migration job killed mid-batch by a deploy SIGTERM, leaving orphaned rows because cancellation was handled per-statement. 14 2. **Deadline budget, not a boolean.** Unbroken propagation means an inherited, already-partially-consumed deadline reaches the DAO call. If upstream work (e.g., a connector call) already burned most of the HTTP handler's timeout, the DAO gets a shrinking remainder and starts timing out under *normal* latency, not just failure — the good case starts looking like the bad case. 15 3. **Sub-interfaces are not uniformly cancel-safe.** Credential writes during auth are exactly the path that caused a real incident: a retry re-issued a credential write against an already-expired derived context and silently no-op'd instead of erroring. Wants explicit naming of which write paths deliberately detach from the inbound context (via `context.WithoutCancel` or a fresh background context) for durability, distinct from read paths that don't need it. 16 17 ## Ruling (Renata Cole) 18 19 All three accepted; no decline. 20 21 1. Any `Repository` method that writes to more than one table (e.g., a profile+credential save) runs inside a single `Tx`. Context cancellation during that transaction triggers `Tx.Rollback`, never a partial commit — this is a stated requirement on the `Repository` implementation contract, not left to each backend's discretion. 22 2. Deadline propagation is a budget-allocation decision, stated explicitly in S3's middleware design: the HTTP-boundary timeout must be sized to cover realistic p99 DAO latency *plus* any upstream work (e.g., no DAO call should ever be handed a near-zero remaining deadline as a matter of routine operation). This gets a concrete number attached when S3's middleware design is written, not left as "some deadline." 23 3. Credential writes are named explicitly as the path that needs a detach point for durability, distinct from profile reads and other cancel-safe paths, which propagate the inbound context unmodified. This distinction is documented at the point each such method is implemented, not left implicit. 24 25 ## Status 26 27 Ruled. Carries into S2 (Repository implementation contract: transaction scoping, detach points) and S3 (middleware deadline budget). 28 29 ## Resolved against 05's contract (2026-09-12) 30 31 Oren's first-pass review of 05's DAO contract flagged a real tension: this ruling's item 1 requires any multi-table write to run in a single `Tx`, but the contract as first landed had no method spanning both `user_profile` and `user_credential`, and explicitly barred 03 from composing a cross-call transaction — leaving registration's profile+credential creation with no atomic path at all. 05 resolved it by adding `CreateProfileWithCredential(ctx, p, c)` on the `Repository` composite (05's §3b) — the one cross-table operation in the contract, DAO-generated IDs for both rows, single transaction, `c.UserID` ignored on input and set from the just-created profile. This ruling's item 1 now has exactly one caller-visible method satisfying it, rather than being a requirement with no concrete instance. 32 33 ## Consistency-pass fixes (2026-09-12) 34 35 **F19 — a real contradiction with 05's contract, not just stale wording.** 05's `multi-db-strategy.md` states "`Context` is first on every method and cancellation is honoured" — a blanket guarantee. Item 3 above, as originally written, said credential writes detach via `context.WithoutCancel` *inside the DAO*, which would mean the DAO does not honor cancellation on those specific methods — directly contradicting 05's guarantee. Fixed: **the detach happens in the handler, before the DAO call, not inside the DAO.** The handler passes the DAO a fresh, independently-bounded context for a credential write (not derived from the inbound request's cancellation tree, but with its own timeout) — from the DAO's perspective, cancellation of the context it was given is still honored unconditionally, exactly as 05's contract states. The DAO layer's guarantee is never weakened; the detach decision is a handler-layer policy about *which* context to hand the DAO, not an exception the DAO carves out for itself. 36 37 **F17 — stale references to a token cache dropped.** Item 3 originally also named "any token-persistence write" as a detach point, left over from before 02's S3 ruling settled on no-cache-by-default for the connector's vendor tokens (`decisions/connector-token-lifecycle-redblue.md`). There is no token-persistence write in the current design, so the phrase is removed (done above) rather than left to describe a mechanism that no longer exists. 38 39 --- 40 Model: sonnet (Nolan Reyes, objection seat; Renata Cole, lead, proposer + ruling). Turns consumed: 1 hire turn + lead synthesis. 41
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/migration-approach.md"
}1 # Migration Approach
2
3 Owner: 05-data-ops (Priya Nandakumar). **Primary receiver: `../04-infra-devops/`** — this is one of the three inputs that track is gated on. Secondary receiver: `../03-engineering-delivery/`, which owns the migration files themselves alongside the DAO code.
4
5 Written to the project's hand-off standard: 04 should be able to act on this without re-deriving my reasoning. Where I state a *requirement*, the mechanism is 04's to choose; where I state a *mechanism*, it is because the requirement cannot be met without it, and I say why.
6
7 Status: **final.** The devil's-advocate objection turn (story S4 in `./PLAN.md`) ran and raised six objections; all six were accepted. The two I named as most worth attacking — the tool choice and the single-runner requirement — were indeed where it landed: R2's *conclusion* survived but its *reasoning* was wrong and has been replaced, and R3 was found to be checking one version number where §2 creates two. Dispositions: `./decisions/migration-approach-objections.md`.
8
9 ## 1. Tool: goose
10
11 **goose** — a Go command-line migration runner that applies numbered `.sql` files in order and records which have been applied in a version table inside the target database. Plain-SQL-first, minimal dependencies. Reasoning is in `./multi-db-strategy.md` and `./research-data-ops-best-practices.md` §3: this schema is "mostly shared DDL, occasionally per-backend", and goose's loose directory model fits that better than golang-migrate's stricter paired up/down files. golang-migrate is an equally defensible tool and I will not contest a swap if 03 already has tooling preferences — **nothing in §3–§6 below changes if the tool changes — with one named exception**: §2's two-invocation mechanism uses goose's `-table` flag, so a swap to golang-migrate means re-deriving how two independent version sequences are kept apart. That is the tool-independence claim's only load-bearing use, and pretending otherwise would be the kind of small overclaim this document is otherwise trying to avoid. Atlas is the schema-as-state alternative worth knowing about; it is more than this take-home needs.
12
13 Version numbering: timestamped filenames during development (avoids collisions when two branches add a migration), converted to a clean sequential 001/002/003 series with `goose fix` — the subcommand that renumbers timestamped files — **before the migrations have been applied anywhere that persists.** That cutoff is the important half: renumbering after application rewrites history the version table already records, so `goose fix` is a pre-first-application step, not a tidy-up before tagging. Not a decision 04 needs to act on — noted so nobody "fixes" the inconsistency later.
14
15 ## 2. Layout
16
17 ```
18 migrations/
19 shared/ # seed data only — no CREATE TABLE
20 postgres/ # all DDL for Postgres + CockroachDB, plus the trigram index
21 sqlite/ # all DDL for SQLite
22 ```
23
24 **Corrected after the cross-track consistency pass (F28).** This section previously put every `CREATE TABLE` in `shared/`, with `postgres/` holding one index file. That was wrong, and wrong in a way the rest of this track's own work had already ruled out: the field-level schema in `multi-db-strategy.md` §5 gives **every** table per-engine DDL — `UUID DEFAULT gen_random_uuid()` vs `TEXT`, `TEXT COLLATE "C"` vs `TEXT COLLATE BINARY`, a regex `CHECK` vs a `GLOB` `CHECK`, `TIMESTAMPTZ` vs `TEXT`, `BYTEA` vs `BLOB`, `BOOLEAN` vs `INTEGER`. A single shared `CREATE TABLE` series cannot express that. SQLite's type affinity would quietly swallow the differing *type names*, which is what made the original claim look survivable, but it does nothing for collation clauses or CHECK syntax — those are hard syntax differences, not affinity.
25
26 The document already carried the right answer as a hypothetical fallback ("per-backend `CREATE TABLE` files and `shared/` reduced to seed data") and left it contingent on an open item marked "under review in story S1". **S1 closed, the review resolved it in favour of the fallback, and this document was not updated** — so a document marked "final" was carrying a provisional default that its own dependency had already overruled. That is the failure, and it is worse than the layout error: the fallback was correct, written down, and left unpromoted.
27
28 So:
29
30 - **`shared/`** carries the `auth_method` seed row and nothing else. No `CREATE TABLE`.
31 - **`postgres/`** carries the full DDL for `auth_method`, `user_profile`, `user_credential` and `deletion_log` in Postgres/CockroachDB types, plus the `pg_trgm` trigram expression index on `LOWER(name)`.
32 - **`sqlite/`** carries the same four tables in SQLite types and collations, and no trigram index — there is no SQLite equivalent and it is omitted rather than faked.
33
34 **Nothing in §3–§6 changes, and neither does 04's design**, exactly as the earlier text promised: the two-invocation mechanism, the separate version tables, R1–R5 and the seed-data rule are untouched. R3's worked example still holds and in fact reads better now — a service can have `shared/` current with the `pg_trgm` index unapplied, which is why fail-closed must check both version tables. (`pg_trgm` is a PostgreSQL extension that indexes three-character fragments of text so that substring searches like `LIKE '%smith%'` can use an index instead of scanning every row — it is what makes name search fast. CockroachDB has the same capability built in; SQLite has no equivalent, so the index is omitted there rather than faked.). That parity gap is documented and deliberate (`./multi-db-strategy.md`); SQLite is the local/dev/demo backend, not a production peer.
35
36 **Mechanism 04 needs:** goose applies one directory per invocation, so a migration run is **two invocations** — `shared/` then the backend directory — and each needs its own version table (`goose -table goose_db_version_shared`, then `-table goose_db_version_<driver>`). One shared table across two directories would interleave version numbers from independent sequences and corrupt the history. Both invocations must succeed for the run to count as successful.
37
38 **That open item is now closed.** It asked whether a single `shared/` DDL series survives all three backends, given that SQLite does not enforce column types the way PostgreSQL does — it assigns each column a loose "type affinity" and falls back to NUMERIC for any type name it does not recognise, `UUID` included. The answer turned out to be no, and for a reason narrower than type affinity: affinity would have absorbed the type names, but collation clauses and CHECK syntax differ outright. Resolved in favour of the per-backend split above. No open items remain in this document.
39
40 ## 3. Requirements on 04 — the part I actually need decided
41
42 **R1. Migrations run as a separate step that completes before any service replica starts.** Not on service boot. Two replicas booting together would both attempt to migrate, and see R2 for why the database will not save you from that. A dedicated job/init step that must exit zero before the service rolls.
43
44 **R2. Exactly one migration runner at a time, enforced by the platform — not by the database.** This is the requirement I most want 04 to read carefully, because the obvious mechanism does not work here. An *advisory lock* is a named lock an application asks the database to hold on its behalf; the database enforces nothing about what it protects, so it is purely a convention between cooperating clients — the usual way to stop two copies of a job running at once. `pg_advisory_lock` is the standard answer on PostgreSQL, but on CockroachDB **session-scoped advisory locks error out, and the `pg_try_advisory_lock` variants silently no-op** — a try-lock returns as though it succeeded and lets a second runner proceed (sourced in `./research-cockroachdb-postgres-semantics.md` §5; only transaction-scoped `pg_advisory_xact_lock` works there). On SQLite the concept is meaningless. A silent false success is a worse failure than an error. **The reason this argument originally gave was wrong, and is replaced.** The first version reasoned "a locking strategy that only works on one backend is not a strategy." That was true of a premise since corrected: `pg_advisory_xact_lock` *does* work on CockroachDB, and SQLite is a single-file local backend where concurrent migration runners are not a scenario that exists — so a transaction-scoped lock works on both backends that can actually have replicas. An objection turn caught that I had over-rotated one research finding into a platform requirement.
45
46 **The conclusion survives on a different and better reason: a transaction-scoped lock cannot cover a migration run.** `pg_advisory_xact_lock` is released when its transaction ends, and goose applies each migration file in its own transaction. The lock would therefore be released *between files*, letting a second runner interleave mid-run — which is precisely the failure it was supposed to prevent. Session-scoped locks, which would span the whole run, are the ones CockroachDB does not support. So the database can offer either the right scope or the right engine coverage, never both.
47
48 So mutual exclusion has to come from the orchestration layer — a job with parallelism 1, a pipeline stage that cannot run concurrently with itself, whatever 04's platform offers. **This is a genuinely open question handed to 04, not a resolved item written up for information.** If 04 concludes the platform cannot guarantee single-runner execution, tell me and I will treat it as a real constraint on the design rather than a detail.
49
50 **A cheap portable backstop worth adding regardless — and an honest gap.** goose's version table is the one artifact all three backends share, so a unique constraint on its `version_id` would make a second concurrent runner fail on insert: portable, free, no platform feature required. **Whether goose's default version table already carries that constraint is unverified here** — I am not asserting it either way, because the objection that raised this correctly pointed out that the document claimed the database could not help without examining the one database-level mechanism goose already creates. The actionable form does not depend on the answer: **add a unique index on `version_id` ourselves in the first migration**, which is portable across all three backends and costs nothing if goose already does it. This is a backstop, not a replacement for R2 — it turns a silent interleave into a loud failure, which is the property actually worth buying.
51
52 **R3. The service fails closed when the schema is behind — checking BOTH version tables.** §2 creates two independent version sequences (`shared/` and the backend directory), so "the applied version" is two numbers, not one. The check must compare both, and must know which backend directory applies to the engine it is connected to. Checking only `shared/` would let a service pass with the `pg_trgm` index unapplied — caught by an objection turn, and it would have been a passing check that proved nothing. On startup the service compares both applied versions against the versions it was built against and refuses to serve if either is behind, rather than serving traffic against a schema missing a column it will query. The requirement is mine; whether the expected version is embedded at build time or derived from the migration files shipped in the image is 03's and 04's call.
53
54 **R4. Two distinct database credentials, not one.**
55 - *Migration runner*: DDL rights (`CREATE`/`ALTER`/`DROP`, index creation) plus, on PostgreSQL only, whatever `CREATE EXTENSION pg_trgm` requires — on managed Postgres this is usually an allow-listed extension, otherwise it needs elevated privileges or the platform pre-installing it. CockroachDB needs nothing here; trigram indexes are native. SQLite has no privilege model at all.
56 - *Runtime service user*: DML only (`SELECT`/`INSERT`/`UPDATE`/`DELETE`). **No DDL.** A running service has no legitimate reason to alter the schema, and a service account that can `DROP TABLE user_profile` turns a SQL-injection finding into data destruction rather than data disclosure.
57
58 Both credentials belong on 02's secrets inventory; I am flagging the requirement, not claiming that surface.
59
60 **R5. Rollback is not the recovery plan for data.** Down migrations will exist for the base schema and are genuinely useful in development. They are not a recovery mechanism in production: reversing a migration that dropped a column does not bring the data back, and the data in question here is PII. Recovery from a destructive migration is backup-restore, and backup policy is 04's. I raise it because "we have down migrations" reads like a safety net and is not one.
61
62 ## 4. Seed data
63
64 The `auth_method` table is seeded with one row, `"password"`. It ships as a migration file in `shared/`, not as a runtime bootstrap in the service — a service that writes its own lookup rows on boot is a service that can write them wrongly, concurrently, on every replica.
65
66 Idempotent via `INSERT ... ON CONFLICT (name) DO NOTHING`, which the unique constraint on `auth_method.name` already supports and which all three backends accept (the same portable upsert form the DAO uses — see `./multi-db-strategy.md`). goose will not re-run an applied migration anyway; the `ON CONFLICT` makes the file safe against a database someone seeded by hand, which in practice is how this breaks.
67
68 New auth methods after `"password"` — `passkey`, `otp`, `oidc` — are **inserted as data, not added as migrations**. That is the entire point of the lookup-table design over a native enum: adding a supported method is an operational action, not a schema change and not a deploy. If 04 finds itself asked to write a migration to add an auth method, that is a signal something has been misunderstood upstream — flag it to me.
69
70 ## 5. Explicitly not owned by this document
71
72 Named so 04 does not read a boundary into this doc that isn't there: where the databases actually run; how the pipeline invokes goose; environment topology and how many of them there are; backup schedule and retention of backups; connection pooling; monitoring of migration runs. All 04's. This document says *what must be true* about migration execution and *why*, and stops there.
73
74 Separately not mine: the **retention sweep and deletion job** are governance mechanisms, not schema migrations, and they are specified in `./pii-governance.md` (policy shape) — not here. Do not implement retention as a migration.
75
76 ## 6. The abstraction ruling — now closed, and it changes nothing here
77
78 Story S3 in `./PLAN.md` — the ruling on the multi-database abstraction shape — **is now ruled**: per-driver implementations behind one interface, confirmed. Full reasoning in `./decisions/multi-db-abstraction.md`. Codegen (sqlc) turned out to be orthogonal to the question rather than a competing option, and is left to 03 as an implementation preference.
79
80 This section originally said "nothing in this document is waiting on S3," and that held: sqlc generates Go from SQL and does not manage schema versions, so the layout, the two-invocation mechanism, R1–R5 and the seed-data rule were never exposed to the outcome either way. **Nothing in this document changed when S3 closed.** It is recorded rather than deleted so 04 can see that the dependency was correctly assessed rather than quietly dropped.
81
82 One thing did change, in R2, and it came from research rather than from S3 — see the advisory-lock correction there.
83
84 ---
85
86 *AI tooling note: drafted by Claude Opus 5 in the 05-data-ops track-lead session, from this track's own research doc and strategy doc, then put through a single devil's-advocate objection turn (Beatriz Achterberg, Spreadsheet archetype, sonnet) before hand-off. Per `../CASTING.md` §5, the objection and its disposition are recorded in `./decisions/`.*
87 {
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/pii-governance.md"
}1 # PII Data Governance Note 2 3 Owner: 05-data-ops. Covers `user_profile` retention, IDP-cached PII (question 3's `/identity` response), and how retention interacts with `user_credential`. Threat modeling and access control for this data are `02-ai-security-architecture`'s call, not restated here — see `../02-ai-security-architecture/CLAUDE.md`. 4 5 ## Data minimization 6 7 The schema collects only the fields the assignment names: name, phone, and the specific address subfields the `/identity` endpoint returns (`street_address`, `locality`, `region`, `postal_code`, `country`). No speculative fields (e.g., date of birth, government ID) were added. If a future requirement needs more PII, that's a deliberate schema change and a deliberate governance decision, not a default to expand into. 8 9 ## Retention 10 11 `user_profile` rows are not retained indefinitely by default. Two clocks apply, distinguished by the `source` column: 12 13 - **`source = 'direct'`** — profile created through this system's own registration flow. Retention window is a product/legal decision this track doesn't own outright, but the schema supports enforcing whatever window is chosen: `created_at`/`updated_at` plus a scheduled deletion job keyed on those columns. 14 - **`source = 'idp_cache'`** — profile hydrated from a third-party IDP connector's `/identity` response. This data is subject to the **same retention obligation as directly-collected PII** — caching it does not reset or relax that obligation. If anything it adds a vendor-processor relationship on top: this system is now downstream custodian of another party's collected PII, which should be documented as a data-flow (third-party collection → this system → disposal) rather than left implicit. 15 16 Tagging cache rows distinctly by `source` is what makes a targeted deletion request or a bulk retention sweep possible without touching directly-collected profiles by accident. 17 18 ## What must never happen, structurally enforced 19 20 - **`user_credential` never carries PII.** No name, phone, or address column exists on that table, and the `UserCredential` type described in `multi-db-strategy.md` has no fields that could hold it. A credential-store breach exposes usernames and hashes, not addresses. 21 - **`user_profile` never carries a secret.** No password hash or raw secret column exists on that table. A profile-store breach exposes PII, not authentication material. 22 - **The third-party IDP's own `/auth` credentials (the `username`/`password` this system POSTs to the vendor's `/auth` endpoint) are never persisted in `user_credential` or anywhere else in this schema.** That table is for this system's own login credentials. Storing a vendor's auth secret here would mean this system is custodian of a third party's credential material with no assignment requirement to do so — a clear scope and governance violation, not just a modeling mistake. 23 24 This separation is treated as a governance control, not merely a normalization convenience: it's the reason a breach of one table doesn't cascade into exposing the other class of data. 25 26 ## Retention windows (S5) 27 28 **Every number below is a proposed POC default pending a product/legal ruling.** One asymmetry belongs stated plainly rather than buried: GDPR Art. 5(1)(e) storage limitation is a **principle** — data kept "no longer than is necessary" — demanding a window someone justifies. It is not a number anyone can look up. So every basis below is either an engineering rationale or the honest word *unknown*. There is no third kind, and a citation implying a regulation supplies a specific duration would be a false statement in a client-facing document. 29 30 | Data class | Clock column | Proposed window | Basis | Disposal | What cascades | Ratifier | Status | 31 |---|---|---|---|---|---|---|---| 32 | `user_profile`, `source='direct'` | `updated_at` | **24 months** since last update | *unknown — needs product/legal ruling.* Engineering basis only: long enough that a dormant account surviving a slow re-engagement cycle is not destroyed, short enough to be a defensible answer to "why do you still hold this." No regulation supplies this number | Hard delete | Cascade FK destroys all `user_credential` rows — the account stops existing | unknown — no named legal owner on this POC | Proposed | 33 | `user_profile`, `source='idp_cache'` | `updated_at` | **30 days** since last hydration | Engineering: **a cache is not a record.** Re-hydration costs one `/identity` call, so deleting too early costs a vendor round-trip. Vendor data also goes stale — a 14-month-old cached address is a correctness problem before it is a privacy one. We are downstream custodian of another party's collected PII with no independent relationship to the subject, which argues for the shortest window that still functions | Hard delete | Cascade FK; a cached profile should not normally have credentials — if it does, it is no longer merely a cache and product must say which clock wins | unknown | Proposed | 34 | `user_profile`, `source='idp_cache'`, **no referencing `user_credential`** (orphaned cache) | **`created_at`**, deliberately not `updated_at` | **7 days** since creation | Engineering: lookup residue, not an account — someone called `/identity`, we cached the answer, nothing was ever built on it. **The clock is `created_at` because `updated_at` is touched by the read and re-hydration path: an orphan re-read on any schedule would keep resetting its own expiry and never die.** Closing that loop is the entire reason this row exists | Hard delete | Nothing — by definition it has no children | unknown | Proposed | 35 | `deletion_log` (the audit trail itself) | `deleted_at` — the entry's own timestamp | **Proposed, pending ruling — see basis** | **unknown — needs a legal ruling, and this cell is the point.** The log exists to evidence that a deletion happened, which is only useful for as long as someone may ask. That period is a legal question about limitation windows and regulator expectations, not an engineering one, and no engineering rationale can substitute for it. Note the trap: the log is PII-free by construction, so it is *not* itself subject to the storage-limitation clock the other rows answer to — which means nothing forces a window on it, and "keep it forever" is the path of least resistance rather than a decision | Hard delete of expired entries | Nothing — no FK, by design | unknown — needs product/legal ruling | **Proposed** | 36 | `user_credential` | — | **No independent window** | Wholly governed by its profile's window via the cascade FK. A credential cannot outlive its profile; a credential row with no profile cannot exist. A second clock could only ever disagree with the first, with no resolution rule | n/a | n/a | **Ruled** | 37 38 ### Two consequences that surprise people, stated loudly 39 40 1. **Deleting a profile silently destroys its credentials.** The account ceases to exist; there is no "profile deleted, login preserved" state. Deliberate. 41 2. **The reverse does not hold.** Deleting a credential leaves the profile standing. **A user who removes their last login method still has PII in this database, running on the `direct` clock.** If product wants "removing your last credential deletes the account," that is a policy decision, not a schema one — and it is currently *not* what the schema does. 42 43 ### The cache window is "since last use", not "since first seen" 44 45 Re-hydration moves `updated_at`, so an actively-used cache row can live indefinitely. That is correct, and it must be written down, because it means the 30-day window measures **time since last use**. It is also exactly why the orphan row is keyed on `created_at`: the two rules are one rule seen from two sides — a row that is still being used should survive, and a row that is only being *re-read by the sweep's own neighbours* should not be able to refresh itself into immortality. 46 47 ## The deletion mechanism (S5) 48 49 | Trigger | Selection predicate | Scope | Effect on `user_credential` | Disposal | Evidence left behind | What 04 provides | Failure mode if it never runs | 50 |---|---|---|---|---|---|---|---| 51 | **Bulk retention sweep** (scheduled) | `source` = the class, plus that class's clock older than that class's window; orphan class additionally requires no referencing credential. **One class per pass — never a mixed-class sweep**, because a single predicate spanning two clocks is how the wrong window gets applied to the wrong rows | Many rows | Cascade delete, implicit and unmentioned in the predicate — the sweep selects profiles and destroys credentials as a side effect. Stated because it is invisible at the point of use | **Hard delete** | One row in a separate `deletion_log`: profile UUID, `source`, `deleted_at`, reason `retention_sweep`, job run id. **No name, phone or address** | Schedule it; run it per class; emit the metrics below | **Nothing errors.** No exception, no alert, no symptom — rows accumulate and the policy is fiction nobody can see is fiction | 52 | **Subject-deletion request** | Exactly one profile id, resolved from the subject's identifier *before* the job runs — resolution is a lookup, not part of the deletion predicate | One profile plus its cascade | Cascade delete — the correct reading of "delete my data" | Hard delete, **same code path as the sweep** | Same `deletion_log` row, reason `subject_request`, plus the external request reference | An operator-invocable path that is **not** "someone runs SQL by hand against production" | Legal exposure rather than silent drift — this one gets noticed, which is why it must not be the only mechanism that works | 53 | **IDP re-hydration overwrite** | Existing `idp_cache` profile matched on the lookup key, fresh `/identity` response | One row | **None** — the profile id is stable across re-hydration, so credentials survive | Upsert in place; old field values are **gone, not versioned** | None, and none wanted | Nothing — request-path behaviour, not a job | n/a — but see the clock-reset note above | 54 55 **Why hard delete and not a soft-delete flag.** A flag means the PII is still sitting in the table, and every query written from that day forward must remember to filter it out. That is how a retention policy becomes decorative while still passing its own tests. 56 57 **Why subject deletion reuses the sweep's code path.** A separate "real delete" path used rarely is a path that is broken when you need it. 58 59 **Why the `deletion_log` holds no PII.** Otherwise the log is just a second copy of the problem with no clock on it. The retained UUID identifies a row that no longer exists and is not linkable to a person once the profile is gone. 60 61 ### Where the mechanism lives in the contract 62 63 Added after the cross-track consistency pass (F21) found that none of the above was reachable from the DAO interface: the policy was specified here and had no method to be implemented against, and no track owned the gap. 64 65 `multi-db-strategy.md` §3c now defines, on the composite: **`DeleteExpired(ctx, class, olderThan) (SweepResult, error)`** — one retention class per call, using that class's own clock column, cascading to credentials and writing a `deletion_log` row in the same transaction — and **`DeleteProfile(ctx, id, reason, externalRef)`** for subject requests, on the same code path. `SweepResult` carries rows examined, rows deleted and the age of the oldest surviving row, which is the health check below. 66 67 ### The health check, which is a requirement rather than a suggestion 68 69 A deletion job that stops running produces no error, no alert and no visible symptom. The only evidence is data that should be absent and isn't. So the sweep emits, per class and per run: **rows examined, rows deleted, and the age of the oldest surviving row in that class.** 70 71 The third is the actual health check. **04 should alert when the age of the oldest surviving row exceeds that class's window plus one sweep interval** — that is the condition that detects a job which has silently stopped, which rows-deleted counts cannot. 72 73 ### Explicitly not modelled: derived data 74 75 Flagged by the hire who wrote the tables above, against her own stated blind spot — everything in them became a row because it fit a column, and this did not. 76 77 **A subject-deletion request currently reaches `user_profile` and, by cascade, `user_credential`. It does not reach derived data** — application logs, metrics, connector traces, or anything else that may have incidentally captured a name, phone or address in transit. That data has no `source` column, no retention clock, and in most cases no primary key to select on. 78 79 This is **not** a decision that derived data is out of scope for deletion. It is an unmodelled area, recorded as such so that its absence is not mistaken for a ruling. It sits across `../02-ai-security-architecture/` (what may be logged at all, and the never-log list) and `../04-infra-devops/` (log retention and where those logs physically live). The cheapest mitigation is upstream of this track entirely: if PII never enters a log, there is nothing to delete from it — which makes 02's never-log list the real control here, not a deletion job this track could specify. 80 81 ## Handoff notes 82 83 - `02-ai-security-architecture` should build credential hashing (algorithm, cost factor, salt handling) on top of the `secret`/`hash_algo`/`hash_cost` columns already reserved in the schema — flagged directly to Marcus once this schema landed. 84 - `04-infra-devops` owns where retention sweeps and deletion jobs actually run (cron, scheduled job, pipeline step) — this note specifies the policy shape, not the execution mechanism. 85
{
"file_path": "/Users/warrenthompson/Source/LoginID/04-infra-devops/observability.md"
}1 # Observability + Never-Log Enforcement
2
3 Design-only. Covers logging, metrics, and tracing for both services, and — the harder half — how `handoff-04-secrets.md`'s never-log list gets enforced as a mechanism, not just stated as a policy.
4
5 ## Logging
6
7 Structured logging (`slog`, Go stdlib — no third-party logging library needed for two services this size), JSON output, one line per event. Every log line that touches a request carries `sub` (caller identity), the endpoint, status, and a request id — never the request or response body wholesale. This is the pattern the never-log list requires (hand-off item 8: "full request bodies of `POST /auth` and `POST /identity`... both contain items above by construction") — the enforcement mechanism is that handlers log a small, explicit field set assembled by hand, never `log.Printf("%+v", req)` or equivalent reflection-based dumps of a request/response struct. A struct that contains a password field can't leak through a log statement that never receives the struct.
8
9 **Enforcement beyond "write it carefully":**
10 - A lint rule (a `golangci-lint` custom check, or a simple `go vet`-style grep in CI) that flags any log call passing a whole request/response struct, a `*http.Request` body, or any variable named/typed to match the never-log list's fields (`password`, `token`, `secret`, `dsn`, `client_secret`). Not foolproof — a determined developer can rename around it — but it catches the accidental case, which is the common one.
11 - Error-path discipline: wrapped errors are logged with `err.Error()` truncated/sanitized where the error type is known to carry sensitive detail (a DB driver error can echo the DSN; a vendor HTTP client error can echo the response body per hand-off item 4) — the wrapping layer strips those before the error reaches a log call, not the log call itself deciding case-by-case.
12
13 ## Metrics
14
15 Standard RED metrics (rate, errors, duration) per endpoint, via Prometheus client library, scraped by whatever the lab cluster already runs (not stood up new for this take-home). Labels: method, route, status class — never a label with cardinality tied to user data (no `sub` or phone number as a metric label; that turns a metrics backend into an accidental PII store with no access control at all, which is a worse leak than a log line since metrics systems are typically more widely readable).
16
17 ## Tracing
18
19 Not built out here — a two-service take-home with no running collector doesn't get more credible by naming OpenTelemetry. If ever added, it follows the same field discipline as logging.
20
21 ## The never-log list, operationalized
22
23 Hand-off's nine items map to two enforcement surfaces, not nine separate mechanisms:
24
25 1. **Application-level:** the structured-logging discipline above (explicit field sets, the lint check, error-wrapping sanitization) covers items 1, 2, 5, 6, 7 (password, tokens, JWTs, client secrets, DSN) — these never enter a Go `log`/`slog` call in the first place.
26 2. **Proxy/ingress-level:** item 9 ("search query parameters containing a name or phone fragment... redact or hash before any access log, including proxy and ingress logs 04 controls") is the one item application code can't fully own, since ingress access logs are written by the ingress controller, not the service. Mechanism: an ingress-level log-format override that logs the request path with query string stripped (or a regex redaction on the `phone`/`name` query params specifically) rather than the default full-URL access log format. This is a one-time ingress ConfigMap change, not a per-request code path.
27 3. Item 3 (PII field values from `/identity` or `user_profile`) and item 4 (raw vendor error bodies) are the same discipline as #1 applied to the connector specifically — the connector logs "called vendor X, got status Y," never the response body.
28
29 ## Audit-log access is a separate grant from Secret access
30
31 Per the hand-off's Kubernetes-specific requirements: the log stream carrying `sub`/scope/record-id/timestamp (the "fact of access" the never-log list still permits) is itself sensitive enough to reconstruct who looked up whom, so it gets its own RBAC — a distinct, narrower `Role` bound to an incident-review group, separate from any ServiceAccount's `Secret`-read grant (`./secrets-delivery.md`). Mechanism: if logs go to a cluster-local sink (e.g., Loki), that sink's read API gets its own RBAC/auth in front of it, not the same one guarding `kubectl get secrets`; if logs go to an external platform, the equivalent is a scoped read-only role in that platform, provisioned separately from any cluster credential.
32
33 ## Retention-sweep observability (05's S5 requirement, final)
34
35 Per `../05-data-ops/pii-governance.md`: the retention sweep must emit, per data class per run, three metrics — rows examined, rows deleted, and **the age of the oldest surviving row in that class**. An alert fires when that age exceeds the class's stated retention window plus one sweep interval. This is the one metric in this document tied to a specific reasoning worth repeating: a sweep job that silently stops running produces no error and no symptom on its own — "rows deleted" can't tell a broken sweep from a legitimately empty one, since both report zero. Oldest-surviving-row age is the one number that keeps climbing when the job is dead and holds steady when it's healthy, which is why it's the alerting signal, not just a nice-to-have metric alongside the other two.
36
37 Mechanism: the sweep job (whatever invokes it — a CronJob against the lab cluster, per 05's mechanism-agnostic requirement) emits these as Prometheus metrics with a `class` label matching 05's actual retention classes — `source="direct"`, `source="idp_cache"`, and `orphan` for an orphaned `idp_cache` row (`../05-data-ops/pii-governance.md`), never a label carrying the PII itself, same discipline as the RED-metrics rule above. (An earlier draft of this section invented `vendor_token_cache`/`idp_identity_cache` as class names — those don't correspond to anything 05 tracks and described a connector token cache the S3 ruling never adopted; corrected here.) The alert rule is `oldest_surviving_row_age_seconds{class="X"} > (retention_window_seconds + sweep_interval_seconds)`, evaluated per class.
38
39 **Batched sweeps: only a drained result feeds the metric.** 05's `DeleteExpired` is batched (a bounded `maxRows` per call, `SweepResult.Drained` marking the last batch of a sweep) — `OldestSurvivingAt` is meaningful only on a `Drained == true` result, since mid-sweep the oldest deletable row is still present by construction and would read as stale on every batch but the last. The sweep job emits `oldest_surviving_row_age_seconds` only from drained results, never from an intermediate batch; the alert rule above already only ever sees what's published, so this is a constraint on what the job emits, not an extra filter the alert needs to apply. Getting this wrong means the alert fires on every sweep and gets muted, which is worse than not having it.
40
41 ## Log-sink retention: the deletion-gap enforcement site
42
43 Application logs, metrics, and traces have no `source` column, no clock tied to a `user_profile` row, and no key a subject-deletion request could reach by cascade — a deleted profile's data can still exist in a log line indefinitely. 05's never-log list (`handoff-04-secrets.md`) and this document's enforcement mechanism (above) are the upstream control — if PII never enters a log, there's nothing in a log to expire — but that only holds for the fields on the list. What's left (chiefly the audit trail: `sub`, scope, opaque record id, timestamp — permitted by design, pseudonymous rather than PII-free) still needs a retention window, and that window's enforcement site is **this track's**: the log sink's own retention policy (the Loki/cluster-local sink named under "Audit-log access," above, or the external platform's retention setting if logs go there instead), configured to 05's audit-log retention row in `pii-governance.md`'s table. Not a deletion job 04 writes — a retention setting on infrastructure 04 already operates.
44
45 ## What's deliberately not here
46
47 No SLOs/error-budget policy, no alerting-rule catalog beyond the retention-sweep alert above. A take-home needs the shape of an observability approach and a credible enforcement mechanism for the one hard requirement (never-log); it doesn't need a full SRE runbook.
48
49 ---
50 *AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2's never-log list. Bree's (capability-case) and Callum's (scope-cut) per-deliverable notes reviewed and folded in inline above, per PLAN.md §3.*
51 {
"file_path": "/Users/warrenthompson/Source/LoginID/02-ai-security-architecture/ai-workflow-narrative.md",
"offset": 30,
"limit": 20
}30 The structure above (root brief, five track briefs, scoped subagents) is the skeleton. What actually ran inside it, once Warren authorized hiring, was a second layer this project built on top of Anthropic's documented patterns rather than found in them: each of the five track leads hired a small sub-team of persona-bearing agents — this track hired four (Tomasz Wrede, an Adversary; Helena Marsh, a Principled Architect; Felix Adebayo, a Tinkerer and this lead's deliberate temperament opposite; Ingrid Solano, a Designated Skeptic) — and ran a small catalog of named orchestration patterns (red team/blue team, adversarial pair, structured written debate, independent second review, rotating devil's advocate, three hats) against specific design decisions, each ending in a durable decision file under `decisions/`. **This entire layer — the personas, the variance rules in `../CASTING.md`, the pattern catalog, the rule that every decision artifact records its own model/turn-count footer — is this project's own design, not an Anthropic-documented technique.** It's a deliberate, load-bearing extension built on top of the orchestrator-worker pattern (§"Orchestration" above), motivated by a specific risk Anthropic's own writing doesn't have to worry about but a five-lead org does: five track leads (and the PM) starting from the same base model land in the same temperament quadrant by default, and agreement between agents who all think alike is not evidence of a good decision.
31
32 ### What the pattern catalog actually bought, honestly measured
33
34 This is not a claim that multi-agent debate produces better designs than a single strong session **in general** — that claim was itself put to a structured written debate in this track (S5, `decisions/ai-workflow-claim-debate.md`) and the ruling is reproduced faithfully below, including the position that lost ground. What can be stated and evidenced:
35
36 - **S3 (red/blue, connector token lifecycle):** Tomasz's red seat produced five ranked attack leaves, each shaped as "add a control" (encrypt the cache harder, bind the token, rate-limit more). Felix's alternatives seat — a role that exists specifically to ask "does the thing being protected need to exist" rather than "is the control sufficient" — proposed deleting two of those leaves outright by removing the token cache entirely (fetch-per-call instead of cache-with-a-TTL), rather than hardening it. That's a concrete design change this document can point to that would not have existed without a seat whose brief was structurally different from the seat before it. Tomasz's own rebuttal turn on the remaining three leaves then surfaced a *new* attack variant on leaf 1 (connection reuse attaching one request's token to another, unrelated to the cache Felix had just deleted) that Helena's blue answer hadn't addressed, closed with a new control; conceded leaf 2 cleanly with no new finding; and tightened leaf 3's accepted-risk boundary. **Corrected tally (cross-track consistency pass, F39 — the earlier count of "2 hits, 1 tightening, 2 null" undercounted the rebuttal turn's own catch): 2 leaves deleted by design change, 1 new variant surfaced and closed by the rebuttal turn, 1 boundary tightened, 1 null (leaf 2's clean concession).** The correction matters for exactly the reason this section exists — a narrative reporting its own evidence has to get the evidence right, including when getting it right makes the pattern's case stronger, not weaker.
37 - **S8 (independent second review, three security-graded documents) — the most valuable process evidence in this track, worth stating as a general lesson rather than just an incident report.** `PLAN.md`'s original static roster assigned Helena to review both `threat-model.md` and `api-auth-design.md`. By the time S8 actually ran, that assignment was stale: Helena had since become S4's Proposer and authored the very scope-vocabulary and policy content in `api-auth-design.md` that S8 was meant to check, and had also been S3's blue seat authoring the controls in `connector-security.md`. Reviewing either would have been an author re-reviewing their own work under the label "independent review." **The lesson: independence is a property that has to be checked at the moment a review actually runs, not a role assigned once at planning time and trusted to still hold** — a five-track org where the same small hire roster accumulates authorship across successive debates will decay its own independence guarantees exactly this way if nobody re-checks before each review. Caught here before running (Helena reassigned to `threat-model.md` only, a document she never touched; Tomasz — clean, no Phase 2 involvement in this document — took `api-auth-design.md`; Ingrid kept `connector-security.md`, unaffected), not after.
38
39 With independence actually verified, the reviews earned their cost. **The concrete example worth leading with:** Tomasz found that the decomposition-resistant rate-limit counter S4 had just finished designing — specifically as Ingrid's own fix to her objection 4 in that same debate — was a silent no-op for `profile:read:any`, the scope with the broadest blast radius, because its exemption clause ("without a fresh `reason_code`") was unconditionally satisfied by a scope that already requires a `reason_code` on every call by design. Neither Helena (who wrote the fix) nor Ingrid (whose objection it answered) caught this, because both were reasoning inside "does this satisfy the objection as stated" — the same self-referential blind spot independent review exists to catch, now demonstrated on the review pattern's own prior output, one debate later. Ingrid's separate finding on `connector-security.md` — that a document entirely about protecting a vendor token's custody said nothing about who may call the connector holding it, or what the token authorizes once used — is a different class of question from the one the S3 red/blue run spent its effort on, for the same underlying reason: every S3 participant was already inside the "protect the token" frame. **Corrected tally (cross-track consistency pass, F40 — the earlier "4+4+2=10" undercounted `threat-model.md`'s own list of five items): 11 items across 3 documents — 10 defects plus 1 completeness note — 0 declined, 0 null reviews.** Both numbers belong in this document, not just the ones favorable to the claim.
40 - **Theo's Newcomer cold-read of `handoff-04-secrets.md` (04's track, not this one, but this track's document):** two real gaps (an undefined component's feature status, a trust-boundary reference that required reading a second track's file to parse) that survived this document's own author's review, caught by a reader with zero authorial investment. **2 hits — one review, two findings** (corrected, cross-track consistency pass F41 — this section previously said "1 hit" for an event it had itself just described as "two real gaps").
41
42 ### What the S5 debate actually settled — stated plainly, including the losing position
43
44 Felix argued (Position A) that this evidence shows the pattern produces better designs than a single strong session would. Ingrid argued (Position B) that two data points, both produced by roles this project built specifically to produce exactly this kind of catch, are not evidence of a general effect — a single session working from an explicit checklist ("can this control's leaf be deleted instead," "get one cold read before shipping") could plausibly produce the same catches without four personas and a debate protocol, and nobody has run or estimated that comparison. The ruling (`decisions/ai-workflow-claim-debate.md`) went **against** the general claim and **for** a narrower one: the defensible statement is a structural/mechanism argument, not a statistical one — a seat holding a genuinely different optimization target, or a reader with no authorial stake, can surface a class of error that self-review structurally tends not to, because self-review interrogates the answer it already committed to rather than the frame that produced the question. That is not the same as asserting multi-agent design beats solo design as a population-level effect, and this document does not make that broader claim. Ingrid's insistence that null results (the S3 leaf conceded clean, S8's near-zero null rate by contrast) be counted in the same tally as hits was adopted outright and is reflected in the tallies above, not just the successes.
45
46 ### The external operator agent: named, never contacted
47
48 A real external agent exists in this environment: an operator, human or automated, holding cluster-admin or namespace-admin rights on 04's design-only Kubernetes lab cluster, capable of applying manifests to it. This track's threat model names that operator as a privileged actor and trust boundary (`threat-model.md`, Assumption 6) — not a hypothetical, a real agent this project could technically reach. **It was deliberately kept out of scope for the entire duration of this project: never contacted, nothing deployed, no manifest applied.** This is stated here because an AI-workflow narrative that documents every agent this project *did* use would be incomplete, arguably misleading by omission, if it didn't also state plainly which real agent in the environment it declined to use and why — the boundary was a design decision (design-only reference target), not an oversight or a capability gap.
49 {
"command": "cd /Users/warrenthompson/Source/LoginID\necho \"=== multi-db-strategy head/§1/§3c/E.164 ===\"; sed -n '1,14p' 05-data-ops/multi-db-strategy.md | cut -c1-200; grep -n \"3c\\|DeleteExpired\\|DeleteProfile\\|DeletionLog\\|E.164\\|item 13\\|^13\\.\" 05-data-ops/multi-db-strategy.md | cut -c1-220\necho \"=== 03/PLAN S4 + context ===\"; grep -n \"token lifecycle\\|never-persist\\|TTL, storage\" 03-engineering-delivery/PLAN.md | cut -c1-220\necho \"=== go-layout diagram token lines ===\"; grep -n \"TTL/refresh\\|no-\\|plaintext-persist\\|token lifecycle\" 03-engineering-delivery/decisions/go-layout-debate.md | cut -c1-160\necho \"=== second-review tally ===\"; grep -n \"Tally\\|= 1[01]\\|wait\" 02-ai-security-architecture/decisions/second-review-security-docs.md | cut -c1-260\necho \"=== threat-model secret type ===\"; grep -n \"nullable TEXT\\|BYTEA\\|bytes\\|secret_state\" 02-ai-security-architecture/threat-model.md | cut -c1-220\necho \"=== industry-framing table shape ===\"; grep -n \"table shape\\|discriminator\" 01-product-industry-research-design/industry-framing.md | cut -c1-260\necho \"=== personas phone ===\"; grep -n -i \"phone\" 01-product-industry-research-design/personas-use-cases.md | cut -c1-260\necho \"=== api-auth-design pagination ===\"; grep -n -i \"cursor\\|offset\" 02-ai-security-architecture/api-auth-design.md | cut -c1-300\necho \"=== ci-pipeline race / conformance ===\"; grep -n -i \"race\\|conformance\" 04-infra-devops/ci-pipeline.md | cut -c1-300\necho \"=== 03 PLAN S5 conformance ===\"; grep -n -i \"conformance\" 03-engineering-delivery/PLAN.md 03-engineering-delivery/backlog.md | cut -c1-200\necho \"=== 03 backlog S11 ===\"; grep -n \"S11\\|sweep\\|DeleteExpired\" 03-engineering-delivery/backlog.md | cut -c1-260 | head -20\necho \"=== 04 backlog Story 6 / LT-20 ===\"; grep -n \"Story 6\\|LT-20\\|retention policy\\|sink\" 04-infra-devops/backlog.md | cut -c1-260 | head\necho \"=== LOG tail ===\"; tail -n 12 LOG.md | cut -c1-400\necho \"=== handoff-03 v2 refs ===\"; grep -rn \"handoff-03-auth.md\\` v2\\|handoff-03-auth.md v2\" --include='*.md' . | grep -v '^./.git' | grep -v cross-track | cut -c1-120\necho \"=== APP_MODE in PLANNING / 03 ===\"; grep -rn \"APP_MODE\" PLANNING.md 03-engineering-delivery/*.md | cut -c1-160 || echo \"APP_MODE absent from PLANNING/03\"",
"description": "Targeted greps for remaining stale-reference sites across seams 4, 5, 7, 9, 10, 11"
}=== multi-db-strategy head/§1/§3c/E.164 ===
# Multi-Database Abstraction Strategy
Owner: 05-data-ops. Implemented by: `03-engineering-delivery`, from the prose contract below — this project is still in planning phase, so the interface and schema exist here as design, not as `.go`/`
## The interface contract (prose, not code)
Three repository interfaces, one per entity, each implemented by two backend packages (below) and never containing SQL themselves — the service layer depends only on these shapes:
- **`ProfileRepository`** — `Get(ctx, id) (*UserProfile, error)`; `Search(ctx, query) (results []UserProfile, total int, error)` where `query` filters by name (partial/fuzzy), phone (exact/prefix), re
- **`CredentialRepository`** — `GetByUsername(ctx, username) (*UserCredential, error)` as the primary auth lookup path; `ListByUserID(ctx, userID) ([]UserCredential, error)`; `Create`, `Update`, `Dele
- **`AuthMethodRepository`** — `Get(ctx, id)`, `GetByName(ctx, name)`, `List(ctx, activeOnly bool)`. Read-heavy; writes to this table are an operational action (adding a supported method), not part of
Domain types implied by the above: `UserProfile` (`ID`, `Name`, `Phone`, `StreetAddress`, `Locality`, `Region`, `PostalCode`, `Country`, `Source` — `"direct"` or `"idp_cache"` — `CreatedAt`, `UpdatedA
45:`user_profile` — pure PII, no secrets: `id` (UUID PK), `name`, `phone` (normalized E.164 at write time), `street_address`, `locality`, `region`, `postal_code`, `country` (ISO 3166-1 alpha-2), `source` (`"direct"` or `
104: // as the delete. See §3c.
105: DeleteExpired(ctx context.Context, class RetentionClass, olderThan time.Time, maxRows int) (SweepResult, error)
106: DeleteProfile(ctx context.Context, id string, externalRef *string) error
122: Phone *string // nil if unknown; E.164 when present
192: Phone *string // EXACT match on the E.164-normalized value (not prefix)
251:## 3c. Retention and deletion — added after the consistency pass (F21)
272:**`DeleteExpired(ctx, class, olderThan, maxRows)`** — one class per call, never a mixed-class predicate, because a single predicate spanning two clocks is how the wrong window gets applied to the wrong rows.
276:**`DeleteExpired` must not be called with a request-scoped context.** It is a background operation governed by the job's own timeout; a sweep cancelled halfway because an inbound HTTP request went away is a retention
280:**`DeleteProfile(ctx, id, externalRef)`** — subject-deletion. Writes the `deletion_log` row in the same transaction, with **`reason` set internally to `subject_request`; it is not a parameter.**
282:That is a deliberate narrowing from the first draft, which took `reason` as a caller-supplied string. A free string lets a caller write `retention_sweep` onto a subject-deletion row by copy-paste, which silently dest
308:`DeleteExpired` and `DeleteProfile` (§3c) are not in the table above: both are composite-level, both write `deletion_log` in the same transaction as the delete with the reason code asserted by the method rather than
385:| `phone` | `*string` | `TEXT` | `TEXT` | NULL ok | E.164-normalized at write. named `ck_user_profile_phone_e164`. Postgres/CockroachDB: `CHECK (phone IS NULL OR phone ~ '^\+[1-9][0-9]{1,14}$')`. SQLite has no regex,
484:Mechanism is 03's. The requirement is 05's, and at minimum the suite asserts: identical result sets and identical ordering for the same `Search` across backends; `errors.Is` sentinel parity for every error condition
=== 03/PLAN S4 + context ===
23:| S4. Connector `/auth` + `/identity` | Go client + handlers per the assignment's fixed JSON contracts | 02's `connector-security.md` (token lifecycle: TTL, storage, refresh, never-persist-in-plaintext) | none outboun
=== go-layout diagram token lines ===
45:│ - handlers (search/get │ │ │ - token lifecycle │
46:│ user_profile) │ │ │ (TTL/refresh/no- │
47:│ │ │ │ │ plaintext-persist, │
=== second-review tally ===
43:**Tally, honestly counted (per S5's ruling that null results count too; corrected by the cross-track consistency pass, F40 — "4+4+2=10" undercounted `threat-model.md`'s own list of five items):** 11 items across three documents — 4 defects + 1 completeness
=== threat-model secret type ===
12:Priya's prose schema in `../05-data-ops/multi-db-strategy.md` (the `.sql` files were deliberately wiped with the rest of the code on 2026-09-12; the prose contract is the source of truth until Warren's code go-ahead)
15:- **Salt handling**: no separate salt column is needed or wanted. Argon2id's standard encoded output (the `«REDACTED-ARGON2»` PHC string format) embeds a fresh random salt per row inside t
=== industry-framing table shape ===
36:- **Design the seams so the gap is closeable later without a rewrite.** The `user_credential.method` field is the natural extension point — a value like `"password"` today, `"webauthn"` or `"passkey"` tomorrow, without changing the `method` discriminator: m
=== personas phone ===
19:**Who:** a customer-support agent or fraud/ops analyst who needs to look up a user profile by partial information — a phone number a customer just read aloud, a name with uncertain spelling, an account ID from a ticket — to resolve a support case or investi
21:**Why they need "search," specifically, not just "retrieve":** retrieval-by-ID is the low-friction case; a support workflow rarely starts with a clean ID. It starts with whatever the customer said on the phone. That's why Q2 says "search **and** retrieve" —
29:**Who:** a product owner building a signup/onboarding flow for a service that wants to pre-fill a new user's name, phone, and address rather than making them type it — because the user already has an account with a partner identity provider (the generic "AB
41:- Search on `name` (partial-match, case-folded substring) and `phone` (**exact match only, after E.164 normalization** — a deliberate accepted product limitation, resolved directly with `../05-data-ops/CLAUDE.md` per F9 of `../decisions/cross-track-consiste
58:All three personas share one property worth stating plainly: **none of them are the end user whose data this is.** The person whose name/phone/address sits in `user_profile`, and whose credential sits in `user_credential`, never directly calls any of these
=== api-auth-design pagination ===
59:- **Opaque cursor at the API boundary** (reasoning corrected by the cross-track consistency pass, F-pag): the earlier claim that offset paging itself "turns a capped page size into full-table enumeration" was wrong — a keyset cursor permits the identical page-by-page walk, just statefully. The ac
=== ci-pipeline race / conformance ===
9:3. **Test.** `go test ./... -race` — unit tests against 03's `internal/dao` per-backend implementations: SQLite in-process for the fast path, plus **two** testcontainer jobs against the shared `postgres` package — one on Postgres, one on CockroachDB. Considered running CockroachDB only in the loca
=== 03 PLAN S5 conformance ===
03-engineering-delivery/PLAN.md:24:| S5. Tests | Table-driven unit tests for DAO (mocked driver), handlers (mocked DAO), connector (mocked HTTP), **plus the cross-backend conformance suite 05's per-dr
03-engineering-delivery/backlog.md:5:Split note on the DAO: the two-package abstraction (`postgres` serving Postgres+CockroachDB, `sqlite` separate — `05-data-ops/decisions/multi-db-abstraction.md`) p
03-engineering-delivery/backlog.md:49:- [ ] Every write (`Create`, `Update`, `Upsert`, `Delete`, `CreateProfileWithCredential`) runs through the retry wrapper — enforcement site: the wrapper is the si
03-engineering-delivery/backlog.md:67:- [ ] `Create` generates the UUID in Go (SQLite has no server-side generation) before insert — enforcement site: the `Create` implementation, verified by the conf
03-engineering-delivery/backlog.md:68:- [ ] Search folds case explicitly (`LOWER(col) LIKE LOWER(pattern)`) rather than relying on SQLite's default ASCII case-insensitivity, so behavior is asserted ra
03-engineering-delivery/backlog.md:69:- [ ] `SQLITE_CONSTRAINT_UNIQUE`/`FOREIGNKEY`/`CHECK`/`PRIMARYKEY` map to the same sentinels as the Postgres package's SQLSTATE codes, disambiguated by the same c
03-engineering-delivery/backlog.md:78:## S5. Conformance suite (05's requirement, cross-backend)
=== 03 backlog S11 ===
185:## S11. Retention sweep: DeleteExpired / DeleteProfile wiring
187:**Description:** Consistency-pass finding F21 identified that 05's retention policy (`pii-governance.md`) had no reachable DAO method, and no track owned writing the sweep job itself. 05's contract addendum (`multi-db-strategy.md` §3c, post-Oren-review-fin
191:- [ ] Loops `DeleteExpired` per `RetentionClass` until `Drained` is true, each using the class's own clock as specified (`updated_at` for `direct`/`idp_cache`, `created_at` for orphans) — enforcement site: the sweep invocation loop, one class at a time, no
192:- [ ] `DeleteExpired` is never called with a request-scoped context — the sweep's own job/scheduler context governs it, never an inbound HTTP request's, so a sweep is never silently cancelled because an unrelated request ended — enforcement site: the sweep
193:- [ ] `SweepResult.OldestSurvivingAt` is published to 04's observability **only from a `Drained == true` result** — a non-drained (mid-sweep) batch reports progress, not health, and must not fire the alert; 04 alerts only from the drained result (flagged t
195:- [ ] The `Drained`/`OldestSurvivingAt` coupling gets a one-line doc comment at the field declaration in the eventual Go code (Oren's note) — so a future implementer reading `OldestSurvivingAt` in isolation mid-sweep doesn't treat a stale value as current.
197:**Depends on:** S2; **05: retention sweep contract addendum** (`multi-db-strategy.md` §3c, pending Oren's first-pass review — same process as the original contract); **04: Story 5 — Observability** (`04-infra-devops/backlog.md`, owns where the sweep job ru
=== 04 backlog Story 6 / LT-20 ===
5:Authored now per Phase 4; will be re-checked against the consistency pass's cross-cutting findings (`PLAN.md` §"Cross-cutting — enforcement sites") once that report lands, particularly for Story 6 below. All stories carry `no-code-yet` — Warren's Phase 4 go
104:## Story 6 — Configure log-sink retention for the audit trail
106:**Description:** The consistency pass (`../decisions/cross-track-consistency.md`, "Deletion gap") ruled the enforcement site: a subject-deletion request reaches `user_profile`/`user_credential` by cascade but not application logs, metrics, or the audit tra
110:- [ ] The audit-log stream expires no later than 05's stated window — enforcement site: the log sink's retention configuration (a Loki `retention_period` or equivalent for the chosen sink), not application code.
111:- [ ] The retention configuration is separate from, and does not touch, any other log stream's retention or any Secret-access RBAC (`./secrets-delivery.md`) — enforcement site: sink-level retention is scoped per log stream/index, not global.
=== LOG tail ===
**Phase 3 complete — 05 closes; all five tracks done.** 05 delivered the Go-shaped contract in `multi-db-strategy.md` (accepted by 03 after Oren's first-pass review against 03's sentinel set), `migration-approach.md` final (to 04), `pii-governance.md` with retention windows and a deletion mechanism, two research docs, and four decision artifacts. Priya's own account of what six hire turns, one res
**Phase 4 opened (Warren: "reach out to the product lead and start building out the backlog").** Warren asked whether the product lead was ready to write stories; answer: yes — 01's plan was Jira-shaped from the start. Decision: Epics now, Stories one turn behind the consistency pass (the derived-data deletion gap will likely create a story in nobody's plan, owned jointly by 02/04/05); 01 first be
**Phase 4 — 01 and 05 in Jira; one dependency error found and corrected.** 01: LT-6…LT-12 Done, LT-13 open (pass findings placeholder), four `Blocks` links. 05: LT-14…LT-19 Done, LT-20 open ("Extend subject-deletion to derived data" — the deletion-gap placeholder), seven links; the Story 3 ⇄ Story 4 loop (the contract had to exist to be cold-read and was not stable until the read closed) recorded
**Phase 3 — cross-track consistency pass reported** (`decisions/cross-track-consistency.md`, fresh Fable session, one pass). Fourteen sections (12 seams + vocabulary sweep + deletion gap): 9 CONTRADICTION, 5 GAP, 0 clean AGREE — though the core test passes in eight seams (endpoint shapes, scope vocabulary, byte-identical sentinel set, `DB_DSN_FILE` precedence, issuer/audience literals, R1–R5, rete
**Pass fixes — 01, 03, 04, 05 closed; 02 outstanding.** 04 (Theo): compose rewritten to file-based `secrets:` so no literal secret values remain in `local-dev-loop.md`; observability now uses 05's real retention classes; stale token-cache references removed; the deletion story (LT-20, re-parented from 05 to 04) written with real acceptance criteria — enforcement site is the log-sink retention poli
**Pass fixes — 02 closed; all 52 findings addressed.** Marcus fixed 16 in place without reopening a debate. The design answers: **F3** — our `/auth` returns the vendor token to the authenticated internal caller (`api-service`), which presents it as a header on `/identity`; the connector stays stateless; Naomi applied the matching sentence to `ux-notes.md:27` verbatim. **F13** — the token issuer be
=== handoff-03 v2 refs ===
LOG.md:55:**Phase 3 — 02 closes S5 and S8.** S5 (structured written debate on the AI-workflow narrative's core claim; `d
PLANNING.md:13:| 03 Engineering & Delivery | Renata Cole | plan approved — all consistency-pass fixes closed, incl. F21
PLANNING.md:46:- **Config surface:** environment variables only (12-factor: config lives in the environment, not in chec
PLAN.md:133:2. **Authorization scopes and their semantics** — `02/api-auth-design.md` (post-S4, post-S8) vs. `02/handoff
03-engineering-delivery/backlog.md:98:**Description:** The client-facing handlers in `internal/api` implementing the ass
03-engineering-delivery/backlog.md:101:- [ ] Implements `handoff-03-auth.md` v2's scope vocabulary and pagination/rate-l
03-engineering-delivery/backlog.md:105:- [ ] A `profile:search` query is rejected outright, not truncated, if it would e
03-engineering-delivery/backlog.md:109:**Depends on:** S2; **02: Auth scheme and authorization hand-off** (`handoff-03-a
03-engineering-delivery/backlog.md:118:**Description:** The middleware at the `cmd/api-service` edge validating the clie
03-engineering-delivery/backlog.md:127:**Depends on:** S1; **02: Auth scheme and authorization hand-off** (`handoff-03-a
03-engineering-delivery/backlog.md:136:**Description:** `cmd/idp-connector`'s outbound client and inbound handlers for t
03-engineering-delivery/backlog.md:145:**Depends on:** S1; **02: Connector security spec** (`connector-security.md`, acc
03-engineering-delivery/CLAUDE.md:36:Planning complete (2026-09-12): 4 hires spawned and live, `PLAN.md` approved, Servi
02-ai-security-architecture/backlog.md:175:- [x] The two findings affecting 03's implementation (decomposition-counter s
=== APP_MODE in PLANNING / 03 ==={
"command": "cd /Users/warrenthompson/Source/LoginID\necho \"=== F27 ===\"; grep -o \"race detector[^.]*\\.\" 04-infra-devops/ci-pipeline.md || echo \"no race-detector clause\"; grep -o \"\\-race[^.]*\\.\" 04-infra-devops/ci-pipeline.md | head -3\necho \"=== F34 ===\"; grep -o \"\\`secret\\` ([^)]*)\" 02-ai-security-architecture/threat-model.md | head -2; grep -c \"secret_state\" 02-ai-security-architecture/threat-model.md\necho \"=== second-review:43 ===\"; sed -n '43p' 02-ai-security-architecture/decisions/second-review-security-docs.md | cut -c1-500\necho \"=== observability nine items ===\"; grep -n \"nine items\\|items 10\\|item 10\\|item 11\\|username\" 04-infra-devops/observability.md | cut -c1-160\necho \"=== RetentionClass / orphan naming ===\"; grep -n \"idp_cache_orphan\\|\\\"orphan\\\"\\|orphan\\`\" 05-data-ops/multi-db-strategy.md 04-infra-devops/observability.md 05-data-ops/pii-governance.md | cut -c1-140\necho \"=== handoff-04 row9 used-by ===\"; sed -n '25p' 02-ai-security-architecture/handoff-04-secrets.md | cut -c1-120\necho \"=== issuer Service? ===\"; grep -n \"kind: Service\" -A3 04-infra-devops/containerization-design.md | grep -n \"name:\" ",
"description": "Final confirmations for F27, F34, the S8 tally line, and class-name consistency"
}=== F27 ===
no race-detector clause
-race` — unit tests against 03's `internal/dao` per-backend implementations: SQLite in-process for the fast path, plus **two** testcontainer jobs against the shared `postgres` package — one on Postgres, one on CockroachDB.
-race` is not optional given two DB implementations with genuine concurrent-access paths worth catching in-process.
=== F34 ===
`secret` (nullable, bytes holding the PHC-encoded hash string — `BYTEA`/`BLOB`, Go `[]byte`; corrected by the cross-track consistency pass, F34 — this was previously described here as "nullable TEXT," which is wrong against the landed contract type; NULL where `auth_method.requires_secret = false`, e.g. passkey/WebAuthn)
0
=== second-review:43 ===
**Tally, honestly counted (per S5's ruling that null results count too; corrected by the cross-track consistency pass, F40 — "4+4+2=10" undercounted `threat-model.md`'s own list of five items):** 11 items across three documents — 4 defects + 1 completeness note in `threat-model.md`, 4 defects in `api-auth-design.md`, 2 defects in `connector-security.md` — 10 defects plus 1 completeness note, zero declined, zero null reviews this time: every second-review seat found at least one real, accepted it
=== observability nine items ===
23:Hand-off's nine items map to two enforcement surfaces, not nine separate mechanisms:
=== RetentionClass / orphan naming ===
04-infra-devops/observability.md:37:Mechanism: the sweep job (whatever invokes it — a CronJob against the lab cluster, per 05's mechanism-ag
05-data-ops/multi-db-strategy.md:258:type RetentionClass string // "direct" | "idp_cache" | "idp_cache_orphan"
05-data-ops/multi-db-strategy.md:276:**`DeleteExpired` must not be called with a request-scoped context.** It is a background operation gove
=== handoff-04 row9 used-by ===
| 9 | End-user's vendor password (`POST /auth` request body) | `idp-connector`, transient | Never persisted, never cache
=== issuer Service? ===
2:63-metadata: {name: api-service}{
"command": "cat >> /Users/warrenthompson/Source/LoginID/decisions/cross-track-consistency.md <<'EOF'\n\n---\n\n# Re-run — 2026-09-12, after the five leads' fixes\n\nScope per team-lead: seams 1/4, 3, 5, 7, 9, 10, 11 and the mechanical checks, read fresh from the current files (line numbers below are from the fixed documents). Seams 2, 6, 8, 12 and the vocabulary sweep were not reopened except where a fix touched them. Classification per seam is again by the most severe finding. Regressions — contradictions introduced by a fix — are marked **NEW**.\n\n## Seams 1 and 4 — connector `/auth`/`/identity` and token lifecycle — CONTRADICTION (narrower than before; one regression)\n\n**What changed.** F3 is answered: `connector-security.md:23` now states that our `/auth` \"returns the vendor `access_token` to whichever authenticated `connector:identity-lookup`-scoped caller invoked it\", that the token \"travels as a request header on the subsequent `/identity` call (e.g. `X-Vendor-Access-Token`)\", that the connector \"still never caches or persists the token\", and that \"it is the caller's responsibility — not the connector's — to hold the token for the short span between the two calls.\" `ux-notes.md:27` carries the matching sentence. F5 is closed (`handoff-03-auth.md:10` no longer carries the contradicting parenthetical). Three of the F17 stale-cache sites are fixed (`secrets-delivery.md:16-17`, `observability.md:37`, `context-propagation.md:37`).\n\n**Still open — F17, three sites untouched.** `handoff-04-secrets.md:22` row 7 still says the red/blue is \"(in progress)\" with caching as the \"current direction\"; `:21` row 6 still says \"If cached at all\"; `:55` still says the connector \"may [cache], under the conditions in #6\". `03/PLAN.md:23` still consumes \"token lifecycle: TTL, storage, refresh, never-persist-in-plaintext\"; the go-layout diagram (`go-layout-debate.md:45-47`) still reads \"TTL/refresh/no-plaintext-persist\". Owners: Marcus (three lines in one document), Renata (two one-line edits).\n\n**NEW — F3's resolution contradicts the rest of `connector-security.md` §1–§2 and the attack-tree record.** With the caller holding the token between calls: `:19` \"the token is fetched fresh from `/auth` for each `/identity` call\" and `:20` \"A retried `/identity` call re-fetches via `/auth`\" are no longer possible — the connector holds neither the user's password (row 9) nor a token of its own to re-fetch with; `:28` \"Token refresh/re-auth on expiry handled by the connector transparently — a caller of our `/identity` endpoint should not need to know or manage vendor token lifecycle\" is the opposite of `:23`'s \"it is the caller's responsibility … to hold the token\"; `:20` \"not passed up the call stack beyond the vendor-call function\" cannot hold for a token the `/auth` handler serializes into an HTTP response. The attack-tree row 3 (`:81`) still justifies the accepted replay risk by \"fetch-per-call bounds the exposure window to one request's execution scope\"; the window now spans the caller's hold, which `ux-notes.md:27` acknowledges and the record does not. Owner: Marcus. Smallest fix, one document: rewrite `:19-20` and `:28` so the connector's statelessness is stated as \"uses the presented token once and discards it; re-auth is the caller's `/auth` call, not the connector's\", and add one sentence to row 3 naming the caller's hold as part of the accepted window.\n\n**NEW — 01's F2 fix and 02's F3 fix are incompatible.** `api-connector-design-rationale.md:40,72` now say `Authenticate`/`FetchIdentity` are \"the vendor adapter's internal interface, not the connector's exported operation … The adapter's one exported method is a single identity-lookup call (e.g. `LookupIdentity(username, password, phone, name) → PII`)\" and \"the calling code … never sees, holds, or passes a token between two calls of its own.\" Under 02's resolution the connector's `/auth` handler must return a token and its `/identity` handler must accept one, so the adapter must expose the two steps separately and the caller (`api-service`) does hold the token. A single `LookupIdentity(username, password, phone, name)` also has no way to serve the assignment's two endpoints, whose bodies split those four fields across two calls. Owner: Naomi — 02's resolution is anchored to the assignment's `/auth` contract, so 01 is the document to change: restore the two-method adapter, and state that the token crosses the connector's HTTP boundary to `api-service` (per `connector-security.md:23`) but never to the browser or end user.\n\n**Minor.** `handoff-04-secrets.md:25` row 9 lists the vendor password as used by \"`idp-connector`, transient\" — it now also transits `api-service` on its way to the connector's `/auth`; add `api-service` to the row so the never-log obligation is visibly on both binaries.\n\n## Seam 3 — Config surface and secrets — GAP (was CONTRADICTION)\n\n**What changed.** F12 fixed (`handoff-04-secrets.md:3` is v2; 04's five references agree). F13 fixed at its source and downstream: `handoff-04-secrets.md:8` and row 2 (`:17`) define the issuer as \"a second Deployment of the same `api-service` image … with its own ServiceAccount that alone mounts the signing-key Secret\"; `containerization-design.md:39,68-108` adds `api-service-issuer` with `APP_MODE: issuer` and removes the mount from `api-service`; `secrets-delivery.md:12,24` state why RBAC alone could not do it. F14 fixed: `local-dev-loop.md:14-48` uses compose file-based `secrets:` from an untracked `.dev-secrets/`, no literal secret values remain. F15 fixed: inventory row 10 (`handoff-04-secrets.md:26`); `PLANNING.md:46` adds `CONNECTOR_JWT_AUDIENCE`, `CONNECTOR_CLIENT_ID`/`CONNECTOR_CLIENT_SECRET`. `DB_DSN_FILE` precedence and the issuer/audience literals still agree everywhere.\n\n**Still open — leftover of F13.** `handoff-03-auth.md:20` (v3) still says the signing key \"lives with the token-issuing function, which is a mode of `api-service` (not a separate binary — see `handoff-04-secrets.md` Assumption 2), scoped by RBAC so the verification code path and the issuing code path don't share Secret access even though they're the same binary\" — the RBAC-alone claim F13 rejected, now contradicting the Assumption 2 it cites. Owner: Marcus; one sentence.\n\n**NEW — `APP_MODE` is required by three documents and defined by none.** `handoff-04-secrets.md:8` calls it \"an environment variable 03 adds to the config surface\"; `containerization-design.md:95,108` uses it and says it \"is 03's addition to the config surface\"; `PLANNING.md:46` Service boundaries (03's) does not list it, and no 03 document mentions it (grep: zero hits). The config surface's own rule is \"environment variables only … No flags\", so the variable must appear there. Owner: Renata; one entry.\n\n**NEW — the issuer Deployment has no Service.** `containerization-design.md:61-66` defines a `Service` for `api-service` only; `api-service-issuer` (`:68-105`) has none, yet callers must reach its token endpoint and `api-service` must reach its JWKS at `AUTH_JWT_ISSUER = https://auth.loginid-takehome.internal` (`PLANNING.md:46`). Owner: Theo; one manifest stanza.\n\n**Minor.** `PLANNING.md:46` lists `CONNECTOR_CLIENT_SECRET` and `IDP_ABC_CLIENT_SECRET` as environment variables while inventory rows 5 and 10 (`handoff-04-secrets.md:20,26`) say \"file mount\"; unlike `DB_DSN`, no `_FILE` variant is named. Either the config surface names `_FILE` variants or the rows accept env delivery. Owners: Renata/Marcus. `PLANNING.md:46` and `03/CLAUDE.md:36` still cite `handoff-03-auth.md` \"v2\"; v3 added four new requirements on 03 (response masking, empty-body rejection, opaque cursor, in-handler `authorize()` placement — `handoff-03-auth.md:33-39,43`) that 03 has not yet acknowledged accepting.\n\n## Seam 5 — DAO contract — CONTRADICTION (one regression; two carry-overs)\n\n**What changed.** F21 closed by `multi-db-strategy.md` §3c (`:251-286`): `DeleteExpired(ctx, class, olderThan, maxRows) (SweepResult, error)` and `DeleteProfile(ctx, id, externalRef) error` on the composite (`:104-106`), one transaction per batch, `deletion_log` written in the same transaction, reason asserted by the method \"not a parameter\" (`:280-282`), `SweepResult.OldestSurvivingAt` meaningful only when `Drained` (`:268-270`); 03's S11 (`03/backlog.md:185-197`) and 04's `observability.md:39` consume it consistently. F-pag closed: `handoff-03-auth.md:43` and `api-auth-design.md:59` now say \"opaque cursor at the API boundary … may encode the DAO's offset\" with the enumeration reasoning corrected; 05's `Offset int` unchanged. F9 closed: `01/decisions/f9-phone-search-ruling.md`, `personas-use-cases.md:41`, `ux-notes.md:11` all say phone is exact after E.164 normalization with the B-tree index as site. F19 closed: `context-propagation.md:35` moves the detach into the handler; the contract's \"cancellation is honoured\" (`:233`) stands. F7 closed: `error-semantics.md:40` (401 / 403 identical body). F23 closed: `PLANNING.md:47-48`. Sentinel set still byte-identical.\n\n**NEW — `pii-governance.md:65` mis-states the addendum it points to.** It gives `DeleteExpired(ctx, class, olderThan) (SweepResult, error)` — missing the required `maxRows` — and `DeleteProfile(ctx, id, reason, externalRef)` with `reason` as a parameter, which `multi-db-strategy.md:280-282` explicitly rules out (\"`reason` set internally … it is not a parameter … a deliberate narrowing from the first draft\"). The policy document now describes the first draft the contract rejected. Owner: Priya; one sentence.\n\n**Carry-over, still open — F20.** `multi-db-strategy.md:9-13` still says `Update` is \"implemented as a portable upsert\", phone is \"exact/prefix\", pagination is bare \"limit/offset\" and there is one sentinel; all retracted at `:219-227` and `:323-`. No banner was added. Owner: Priya.\n\n**Carry-over, still open — F22.** `multi-db-strategy.md:227` still says resolving an `/identity` payload \"is the connector's problem … The connector decides identity and then calls `Create` or `Update`\", while `go-layout-debate.md:78-80` says the connector \"has no DB access, by design\". Owner: Priya; \"the connector's caller (`api-service`)\".\n\n**Minor.** Class naming: the contract's `RetentionClass` is `\"idp_cache_orphan\"` (`:258`); `observability.md:37` labels the same class `orphan`. Pick one.\n\n## Seam 7 — Migrations — AGREE\n\n**What changed.** F28 closed: `migration-approach.md:17-38` makes `shared/` \"seed data only — no CREATE TABLE\", `postgres/` and `sqlite/` carry all DDL, the correction is visible in place, and \"No open items remain in this document\" (`:38`). R1–R5 unchanged and still matched by `containerization-design.md:116-120` and `ci-pipeline.md`; the two-invocation mechanism and R3's worked example still hold. The goose `version_id` item remains presented as unverified (`:50`) — left open, as instructed. F27 fixed: `ci-pipeline.md:9` no longer claims `-race` catches R2. F26 fixed: `local-dev-loop.md:56`. Nothing provisional remains; no regression found.\n\n## Seam 9 — Retention and deletion — GAP (narrower than before; one regression)\n\n**What changed.** The deletion-gap ruling was adopted: never-log items 10 (driver `DETAIL`/`Key (...)=(...)` text, site = 03's translation layer) and 11 (username) added at `handoff-04-secrets.md:50-51`; `error-semantics.md:42` names the translation layer as the site for item 10; `observability.md:41-43` replaces the mutual disclaimer with \"the log sink's own retention policy … configured to 05's audit-log retention row\"; `04/backlog.md:104-111` Story 6 (LT-20) has real acceptance criteria; `observability.md:37` uses 05's class names; `observability.md:39` consumes `Drained` correctly. Windows 24 months / 30 days / 7 days and the alert rule are unchanged and still agree.\n\n**NEW — 05 added a retention row for the wrong artifact, and 04 points at a row that does not exist.** `pii-governance.md:35` adds a row for \"`deletion_log` (the audit trail itself)\" — the database table — with clock `deleted_at` and disposal \"Hard delete of expired entries\". `observability.md:43` and LT-20 configure the log sink \"to 05's audit-log retention row\" — meaning the *log stream* of `sub`/scope/record-id/timestamp, which has no row in 05's table. Two different artifacts, one word. Furthermore the `deletion_log` table row has no mechanism: `RetentionClass` (`multi-db-strategy.md:258`) has no value for it, so `DeleteExpired` cannot sweep it, and nothing else in the contract deletes from that table. Owner: Priya — add the audit-stream row (window proposed, basis unknown, mechanism = 04's sink retention) and either add a `deletion_log` class to `DeleteExpired` or mark the table's window \"no mechanism yet\"; Theo then cites the right row.\n\n**Minor.** `observability.md:23-27` still says \"Hand-off's nine items\" and maps items 1–9 only; items 10 and 11 are unmapped in 04's operationalization, and the lint list at `:10` still lacks `username`. Owner: Theo.\n\n## Seam 10 — Narrative claims — AGREE\n\nF39 fixed (`ai-workflow-narrative.md:36`: 2 deleted, 1 new variant closed, 1 tightened, 1 null — matching `connector-token-lifecycle-redblue.md:46-48` and `ai-workflow-claim-debate.md:20,29`). F40 fixed and consistent across both documents (`ai-workflow-narrative.md:39`, `second-review-security-docs.md:43`: 11 items, 10 defects plus 1 completeness note). F41 fixed (`:40` \"2 hits — one review, two findings\"). F42 fixed (the stray \"wait\" is gone). The corrections are marked in place rather than silently applied. No regression.\n\n## Seam 11 — Root files — AGREE (trivia only)\n\nRoot `CLAUDE.md:69` Status is current (Phases 0–3 complete, Phase 4 under way). `03/CLAUDE.md:36` and `04/CLAUDE.md:37` are current. All five `PLANNING.md` track rows read \"plan approved\" (`:11-15`). `CASTING.md:141-142` reflect the S8 reassignment. `03/PLAN.md:70-72` annotates the Callum→Nolan rename. Footers: **17 of 17** decision files (including the new `f9-phone-search-ruling.md`) carry model and turns. Trivia: `03/CLAUDE.md:36`, `PLANNING.md:46` and `03/backlog.md:101` still cite `handoff-03-auth.md` v2 (now v3 — see seam 3 on what v3 added).\n\n## Mechanical checks (re-run)\n\n- `profile-gen:start` markers: **6**. Pass.\n- Code artifacts (`*.go`, `*.sql`, `go.mod`, `Dockerfile`): **none**. Pass.\n- `CASTING.md` §8: **18** rows, **18** `done`. Pass.\n- Decision footers: **17/17**. Pass (was 11/14).\n- `.claude/agents/`: 18, all with `model:`. Pass.\n- Stray drafting artifacts (\" — wait, \"): none. `vendor_token_cache`: only as a marked correction. Pass.\n- Working tree: 38 modified files and one new decision file since the pass's commit `3459a6e`; the seven fixed backlogs were not reviewed beyond 03's S11 and 04's Story 6.\n\n## Seams not reopened — carry-overs still open, listed for completeness only\n\nF25 (conformance suite) is now in `03/PLAN.md:24` and `03/backlog.md:78` — closed incidentally. F33/F34 fixed incidentally (`industry-framing.md:36`, `threat-model.md:12`). Still open from seam 12: F51 (phone E.164 normalization has no named enforcement site — `multi-db-strategy.md:45,385`, no §6 row) and F52 partly (F52's sites were added in `handoff-03-auth.md:43-46` — closed). From seam 6: F31 (stale research-doc text) untouched.\n\n## Verdict\n\nCloser, not there. Of the fourteen sections, seams 7, 10 and 11 are now clean, and every one of the six gating findings named in the original verdict has been acted on. But the fixes introduced four contradictions of their own, and they cluster on the one design that matters most: 02's resolution of the vendor-token flow (F3) is contradicted by the unrevised remainder of `connector-security.md` §1–§2 and its attack-tree row 3, and is incompatible with 01's simultaneous F2 fix (a single `LookupIdentity` that never surfaces a token); the F21 addendum is mis-stated by the policy document that depends on it; and the deletion-gap ruling landed as a retention row for the `deletion_log` table while 04 built against an audit-stream row that does not exist. None reopens a debate; each is one document and one to three sentences (owners: Marcus for `connector-security.md` and `handoff-03-auth.md:20`, Naomi for `api-connector-design-rationale.md:40,72`, Priya for `pii-governance.md:35,65`, Renata for `APP_MODE` in `PLANNING.md`, Theo for the issuer Service). With those applied, plus the F17 leftovers and the F20/F22 carry-overs, the submission is internally consistent enough to declare planning done; a third pass is needed only on seams 1/4, 3, 5 and 9, and can be a diff-read rather than a fresh read.\n\n---\nModel: fable (consistency-pass re-run, same fresh-context session). Turns consumed: 1 re-run pass.\nEOF\necho appended; tail -n 3 /Users/warrenthompson/Source/LoginID/decisions/cross-track-consistency.md | cut -c1-80",
"description": "Append the dated Re-run section to the consistency report"
}appended --- Model: fable (consistency-pass re-run, same fresh-context session). Turns consum
{
"to": "team-lead",
"summary": "Re-run verdict: closer, four regressions",
"message": "Re-run appended to decisions/cross-track-consistency.md — verdict: closer, not there; four regressions introduced by the fixes, all one-document edits.\n\nSeams now clean: 7 (F28 done, no provisional items), 10 (all four tallies corrected), 11 (statuses, rows, footers 17/17, mechanical checks all pass).\n\nRegressions (NEW):\n1. connector-security.md — F3's resolution at :23 (caller holds the token, connector stateless) is contradicted by unrevised :19-20 (\"fetched fresh per /identity\", \"retry re-fetches via /auth\"), :28 (\"re-auth handled by the connector transparently\"), and attack-tree row 3's \"one request's execution scope\" justification. Owner Marcus.\n2. api-connector-design-rationale.md:40,72 — 01's F2 fix (single LookupIdentity, caller never holds a token) is incompatible with 02's F3 fix. Owner Naomi.\n3. pii-governance.md:65 mis-states the §3c addendum (omits maxRows; gives `reason` as a parameter the contract explicitly rejects). Owner Priya.\n4. Deletion gap: 05 added a retention row for the deletion_log TABLE (no sweep class exists for it); 04's observability:43 and LT-20 cite an audit-STREAM row that doesn't exist. Owners Priya, Theo.\n\nStill open: F17 leftovers (handoff-04-secrets rows 6/7/:55, 03/PLAN:23, go-layout diagram); handoff-03-auth.md:20 still says RBAC-alone on a shared binary; APP_MODE required by 02/04 but absent from PLANNING.md config surface; issuer Deployment has no Service; F20 stale head section and F22 \"connector decides identity\" untouched; 03 hasn't acknowledged handoff-03-auth v3's new requirements.\n\nAfter those, planning can be declared done; a third pass need only diff-read seams 1/4, 3, 5, 9.",
"type": "message",
"recipient": "team-lead",
"content": "Re-run appended to decisions/cross-track-consiste…"
}{"success":true,"message":"Message sent to team-lead's inbox","msg_id":"2bb97c96-5da4-4673-b6cc-33d2ac881f9e","routing":{"sender":"consistency-pass","senderColor":"green","target":"@team-lead","summary":"Re-run verdict: closer, four regressions","content":"Re-run appended to decisions/cross-track-consiste…"}}{
"command": "git status --short | head -40 && echo \"=== seam 13 ===\" && grep -n \"13\\. \\*\\*\" -A6 PLAN.md | cut -c1-900",
"description": "Show working tree and the seam 13 checklist text"
}M 01-product-industry-research-design/api-connector-design-rationale.md M 01-product-industry-research-design/backlog.md M 01-product-industry-research-design/decisions/claims-audit.md M 01-product-industry-research-design/decisions/password-baseline-debate.md M 01-product-industry-research-design/decisions/rationale-cold-read.md M 01-product-industry-research-design/industry-framing.md M 01-product-industry-research-design/personas-use-cases.md M 01-product-industry-research-design/ux-notes.md M 02-ai-security-architecture/CLAUDE.md M 02-ai-security-architecture/ai-workflow-narrative.md M 02-ai-security-architecture/api-auth-design.md M 02-ai-security-architecture/backlog.md M 02-ai-security-architecture/connector-security.md M 02-ai-security-architecture/decisions/second-review-security-docs.md M 02-ai-security-architecture/handoff-03-auth.md M 02-ai-security-architecture/handoff-04-secrets.md M 02-ai-security-architecture/threat-model.md M 03-engineering-delivery/CLAUDE.md M 03-engineering-delivery/PLAN.md M 03-engineering-delivery/backlog.md M 03-engineering-delivery/decisions/context-propagation.md M 03-engineering-delivery/decisions/error-semantics.md M 03-engineering-delivery/decisions/go-layout-debate.md M 04-infra-devops/CLAUDE.md M 04-infra-devops/backlog.md M 04-infra-devops/ci-pipeline.md M 04-infra-devops/containerization-design.md M 04-infra-devops/local-dev-loop.md M 04-infra-devops/observability.md M 04-infra-devops/secrets-delivery.md M 05-data-ops/CLAUDE.md M 05-data-ops/backlog.md M 05-data-ops/migration-approach.md M 05-data-ops/multi-db-strategy.md M 05-data-ops/pii-governance.md M CASTING.md M LOG.md M PLAN.md M PLANNING.md M decisions/cross-track-consistency.md === seam 13 === 143:13. **Cross-cutting — restatements** (principle from 05's re-run close-out, 2026-09-12): a summary of another document is a dependency on it, and restatements are mechanically findable. For every interface signature, table shape, config variable, version label, and retention row that is authoritative in one document, grep every other document that restates it and confirm the restatement matches; a stale restatement is a *contradiction* owned by the restating document's author. **Scope:** flag restatements that assert *current* truth; do not flag quotations of *prior* state — a decision record that says "the contract then described `Search(...)` in a sentence" is history, not a restatement, and demanding edits to such records would destroy most of this project's evidentiary value while training everyone to ignore the check. Superseded prose that still reads as current is a restatement 144-12. **Cross-cutting — enforcement sites** (principle from 05's S2, `05/decisions/search-query-shape.md`): for every caller-visible guarantee stated in any hand-off (`02/handoff-03-auth.md`, `02/handoff-04-secrets.md`, `05/multi-db-strategy.md` S1, `PLANNING.md` Service boundaries), name the enforcement site — DDL constraint, index, normalization step, conformance test, middleware check, CI job. A guarantee with no named site is listed as a *gap*, owned by the hand-off's author. 145- 146-### Phase 4 — Jira (Warren's go: 2026-09-12, "start building out the backlog") 147- 148-Team-lead is the single Jira writer (one authenticated session, one audit trail); leads author. Each lead writes `<track-dir>/backlog.md` — the in-repo, versioned backlog, which is itself a submission artifact — and team-lead transcribes it into project `LT` (cloudId `db488eae-d6cb-4100-92a9-e7a4519fc176`) and writes the issue keys back into the file. 149-
{
"command": "cd /Users/warrenthompson/Source/LoginID\necho \"=== seam 13 rest ===\"; sed -n '143p' PLAN.md | cut -c900-2400\necho; echo \"=== connector-security §1/§2 ===\"; sed -n '16,31p' 02-ai-security-architecture/connector-security.md | cut -c1-1400\necho; echo \"=== attack-tree row 3 ===\"; grep -n \"^| 3 |\" 02-ai-security-architecture/connector-security.md | cut -c1-1600\necho; echo \"=== 01 rationale :40 :72 ===\"; sed -n '40p;72p' 01-product-industry-research-design/api-connector-design-rationale.md | cut -c1-1000\necho; echo \"=== ux-notes:27 ===\"; sed -n '27p' 01-product-industry-research-design/ux-notes.md | cut -c1-900\necho; echo \"=== handoff-03-auth:20 ===\"; sed -n '20p' 02-ai-security-architecture/handoff-03-auth.md | cut -c1-1200\necho; echo \"=== handoff-04 rows 6/7 + :55 ===\"; sed -n '21,22p' 02-ai-security-architecture/handoff-04-secrets.md | cut -c1-900; grep -n \"What 04 does not decide\" -A2 02-ai-security-architecture/handoff-04-secrets.md | cut -c1-500\necho; echo \"=== PLANNING config surface ===\"; sed -n '46p' PLANNING.md | cut -c1-2200\necho; echo \"=== containerization issuer Service + reachability ===\"; grep -n \"api-service-issuer\\|reachab\\|JWKS\\|auth.loginid-takehome\" 04-infra-devops/containerization-design.md | cut -c1-300",
"description": "Read the fixed seam 1/4 and seam 3 sections"
}=== seam 13 rest ===
emedy (ruled 2026-09-12 on 05's argument): **strike through a superseded *claim*; delete a superseded *copy*, with a banner recording what was removed and why.** A claim is evidence of what someone once believed and is worth keeping; a copy of another document's contents is a liability whose only value was being accurate, and a struck-through copy would still have to be maintained forever. Six instances in one track had the same cause — the authoritative document was updated and its summaries were not: F28's provisional item, the `PLAN.md` S4 cross-reference, the §3c summary at `pii-governance.md:76-77`, pre-review signatures in `05/backlog.md:127`, and two assertions in `multi-db-strategy.md`'s first-wave preamble (`:9-10`) that the Fable pass itself missed because they sat in a section a reader treats as superseded.
=== connector-security §1/§2 ===
### 1. What happens to the vendor `access_token` once retrieved
- **Never logged.** The `/auth` request body contains a vendor password; the response contains a bearer token. A naive "log the request and response for debugging" habit puts both in the same log line — this is called out explicitly because it's the single most common way a connector like this leaks a credential in practice, not a hypothetical.
- **No persistent or cached token, by default — updated for F3's caller-holds-the-token model (seam re-run regression fix).** The S3 ruling's no-cache posture stands, but its original wording assumed the connector itself calls `/auth` immediately before every `/identity` call, chaining the two internally. That's superseded by F3: our own `/auth` and `/identity` are genuinely separate calls from our caller's perspective (matching the assignment's fixed two-endpoint contract), and it is the **caller** (`api-service`, on the onboarding flow's behalf) that calls our `/auth`, receives the vendor token, and presents it as a header on the following `/identity` call. What the connector still never does: cache or persist the token, write it to any datastore, or hold it any longer than the single request currently being handled. The connector's `/identity` handler uses whatever token its caller presents for that one call and discards it when the call returns — the "no cache" discipline now describes the connector's own handling of a caller-presented token, not a connector-managed fetch-then-cache cycle. Caching is still an explicitly-justified exception requiring its own re-review if it's ever proposed for either side of this exchange; the exception conditions (immutable cache key, TTL, KMS fail-closed) from the S3 ruling are unchanged if that question is ever reopened.
- **Fetch-use-zeroize boundary, restated for the caller-holds-the-token model:** on the `/identity` side, the presented token's scope runs from the point the handler reads it off the request header to the end of that single call, and no further — it is not cached, not passed to any other request, not logged. On the `/auth` side, the token returned to the caller is not retained by the connector after the response is sent. **Retry behavior is now the caller's responsibility, not the connector's**: if `api-service` retries a `/identity` call, whether it re-presents the same token or first calls `/auth` again is its own policy decision based on the token's likely freshness — the connector's only obligation is to treat each presented token as freshly supplied and reject it cleanly (fail closed, generic error) if the vendor rejects it, never silently retrying the vendor call with a stale token on the connector's own initiative. Go gives no guaranteed memory zeroing without unsafe/cgo, so a crash dump taken mid-call could still capture the token in the handler's stack; that residual is accepted given the window is one request, not a cache TTL. The token field is excluded from any generic request/response dump used in error handling — the same log-hygiene discipline below applies to panic traces and debug dumps, not just application logs.
- **The outbound HTTP client to the vendor must construct a fresh, isolated `Authorization` header per request, never inherited from connection reuse.** Connection pooling/keep-alive is the normal default in any HTTP client library; a pooled client that reuses a request-context object or fails to reset the auth header per call can attach request A's vendor token to request B's outbound call — the same cross-tenant leak as a cache-key collision, with no cache required. This is a required implementation discipline, not an assumption left implicit.
- **Scoped narrowly if the vendor supports it** — request only the scope needed for `/identity`, not a broad token, so a leaked token's blast radius is bounded by the vendor's own authorization model as well as ours.
- **Returned once, to the authenticated internal caller only — never to end users, never persisted server-side (resolved by the cross-track consistency pass, F3).** Our own `/auth` returns the vendor `access_token` to whichever authenticated `connector:identity-lookup`-scoped caller invoked it (in this project's shape, `cmd/api-service` acting on an onboarding flow's behalf) — this is what the assignment's `/auth` contract actually requires ("retrieves an `access_token`"), and it is what `../01-product-industry-research-design/ux-notes.md`'s onboarding flow depends on to bridge its two user-facing steps. Because our own `/identity` request body is fixed by the assignment to `{"phone", "name"}` with no credential field, the vendor token travels as a request header on the subsequent `/identity` call (e.g. `X-Vendor-Access-Token`) — this doesn't violate the fixed JSON contract, which governs the body only, not headers. The connector itself still never caches or persists the token (S3's no-cache ruling stands unchanged): each `/identity` call's presented token is used once and discarded, exactly as if it had been freshly fetched, and it is the caller's responsibility — not the connector's — to hold the token for the short span between the two calls. If a caller presents no token, or an expired one, `/identity` fails closed with a generic error that does not distinguish "no token" f
### 2. How `/identity` calls are authenticated to the vendor
- Standard bearer-token usage: the vendor `access_token`, presented by our caller as a request header on our own `/identity`, is forwarded as an `Authorization: Bearer` header to the vendor's identity endpoint (or whatever the vendor's own contract specifies — this connector adapts to the vendor, not the reverse).
- **Token refresh/re-auth on expiry is the caller's responsibility, not the connector's (corrected for F3 — seam re-run regression fix).** The earlier text here said the connector handles this "transparently" so a caller "should not need to know or manage vendor token lifecycle" — that assumed the connector itself owned the fetch-then-use cycle. Under F3's model the caller holds the token between our `/auth` and `/identity` calls, so the caller necessarily manages when to re-authenticate: if our `/identity` rejects a token as expired, it fails closed with a generic error (per §1) and it is the caller's decision to call our `/auth` again, not a retry the connector performs on its own initiative.
- **Vendor calls over TLS only**, certificate validation never disabled, including in local/dev environments (repeating the threat model's point because it's the most common place a "temporary" dev bypass ships to production unnoticed).
- **Our own credentials to call the vendor's `/auth`** (this connector's client id/secret with ABC/XYZ, if the vendor requires connector-level registration in addition to the end-user's own username/password) are runtime-injected secrets, never in source or config committed to the repo — requirement set here, mechanism owned by `../04-infra-devops/CLAUDE.md`.
=== attack-tree row 3 ===
81:| 3 | Vendor token replay — no proof-of-possession/session binding once decrypted for use | Token exfiltrated by any other means within its exposure window | M | H | **Accepted risk.** Bearer tokens under RFC 6750 are possession-equals-authorization by design; PoP/binding (DPoP, RFC 9449; mTLS-bound tokens, RFC 8705) needs vendor-side support this project cannot assume for a generic ABC/XYZ vendor the assignment never specifies. Acceptable *for this system* because the token is never cached or persisted on either side of the exchange — it exists only in the caller's possession for the brief span between our `/auth` and `/identity` calls, and only inside the connector's `/identity` handler for that single request, on the connector's side (**updated for F3's caller-holds-the-token model, seam re-run regression fix** — the original justification described the connector itself fetching and using the token in one function call; the exposure window is now bounded by the same no-cache/no-persist discipline applied across two parties instead of inside one function). Retrying `/identity` with a stale token is the caller's decision, not the connector's own retry-with-reuse — the connector treats each presented token as freshly supplied and never retries the vendor call on its own initiative with a token it already knows is invalid. | — (re-review trigger: a PoP-capable vendor surfacing in 01's research) | **M** — accepted, not eliminated; a real vendor-side constraint, not a design gap, but still "possession = authority" for the token's lifetime | RFC 6750 §5; RFC 9700 §2.5 |
=== 01 rationale :40 :72 ===
- `Authenticate(username, password) → token` and `FetchIdentity(token, phone, name) → PII` map one-to-one onto the connector's own exported `/auth` and `/identity` endpoints, which the assignment fixes as two separate calls — per `../02-ai-security-architecture/connector-security.md`, our `/auth` returns the vendor token to its authenticated caller (`api-service`, on the onboarding flow's behalf), which holds it only for the brief span before presenting it to our own `/identity` as a request header; the connector itself never caches or persists it. Each provider adapter's `Authenticate`/`FetchIdentity` still absorb vendor-specific differences (token format, endpoint shape) behind that common shape, but the token is not hidden from the calling code — the onboarding flow does hold it briefly between the two calls, by design, not as an oversight.
- Build one provider-agnostic interface with ABC and XYZ as its first two adapters. `Authenticate` and `FetchIdentity` map one-to-one onto the connector's own exported `/auth` and `/identity` endpoints (the assignment's fixed contract) — per `../02-ai-security-architecture/connector-security.md`, our `/auth` returns the vendor token to its authenticated caller and our `/identity` accepts that token as a header (not a body field) for the one call it authorizes; the connector itself never caches or persists it. The calling code (onboarding flow, via `api-service`) does hold the token briefly between the two calls — that is the design, not something this interface hides — and still never branches on provider identity.
=== ux-notes:27 ===
**The one design rule this surface enforces:** the partner's password is never visible to, or stored by, this service past the token exchange — the review screen never displays a password field, partner or otherwise, and nothing on this screen persists the credential entered on the previous one. That is the UI-level expression of the connector's `/auth` → `/identity` two-step: a short-lived token stands in for the password everywhere past the first screen. That token is held only by this onboarding flow, only for the span between the two calls, and is never persisted past this signup step — the connector that issues it holds it no longer than that either (`../02-ai-security-architecture/connector-security.md` §1).
=== handoff-03-auth:20 ===
- **Key retrieval:** middleware verifies with the **public** key only (JWKS or a fetched public key — see `handoff-04-secrets.md` row #3). The private signing key never touches the verifying `api-service` Deployment; it lives with the token-issuing function, which is a mode of the same `api-service` image but runs as its **own Deployment** (`APP_MODE=issuer`, its own ServiceAccount), not the same Deployment doing both (corrected in `handoff-04-secrets.md` Assumption 2 and row #2, F13 — RBAC on a Secret's `get` verb doesn't stop a volume already mounted into a shared Deployment's containers, so the separation has to be a second Deployment, not RBAC alone). This is a second `APP_MODE` value for you to wire alongside your existing config surface.
=== handoff-04 rows 6/7 + :55 ===
| 6 | Vendor `access_token` (returned by our own `/auth` to its authenticated caller, presented as a header on our own `/identity` — updated for F3's caller-holds-the-token model, seam re-run regression fix) | `api-service` holds it briefly between the two calls; `idp-connector` only ever sees it as a caller-presented header on a single `/identity` request, never fetches or holds it between calls itself | **Not a deployed secret** — transient on both sides, never cached, never persisted. Neither `api-service` nor `idp-connector` writes it to a datastore (S3's no-cache ruling, restated for the two-party model in `connector-security.md` §1) | Expires on its own; never persisted across restarts by either party | No `Secret` object anywhere in this flow — there is nothing here for 04 to provision | Yes — never, including error paths, on both sides of the exchange |
| 7 | Data-encryption key (KEK) for envelope encryption of any TOTP shared secrets | `api-service` (TOTP) only — **not a committed feature of this submission**; `user_credential.method` is modeled as an extensible lookup table (`../05-data-ops/multi-db-strategy.md`) that supports TOTP as a *possible* value, but no TOTP implementation is planned unless 03 builds one. **The vendor-token-cache use case for this row no longer exists**: S3's red/blue on the connector token lifecycle is closed (`decisions/connector-token-lifecycle-redblue.md`) — no cache by default, and the token is never held by the connector between calls at all under F3's model (row 6), so there is no vendor-token cache for a KEK to encrypt. This row is provisioned only if TOTP materializes — it is a contingency the inventory names in advance, not a live requirement for 04 to build now | Runtime-injected as a file; the KEK
53:## What 04 does not decide (already decided upstream)
54-
55-Token TTLs and claim set (`api-auth-design.md`); scope vocabulary (`handoff-03-auth.md`); password and client-secret hashing (`threat-model.md` Asset 1); the vendor-token exchange model — the connector never caches it, `api-service` holds it only between the two calls (`connector-security.md` §1, F3); PII retention windows (`../05-data-ops/pii-governance.md`).
=== PLANNING config surface ===
- **Config surface:** environment variables only (12-factor: config lives in the environment, not in checked-in files) — `DB_DRIVER` (postgres|cockroachdb|sqlite), `DB_DSN`, `DB_DSN_FILE` (file-mount path; takes precedence over `DB_DSN` when both are set, per 02's `handoff-04-secrets.md` S7 revision — a DSN's embedded password is as exposed by `kubectl describe`/crash dumps/env-in-child-process as any other secret in that inventory, so `DB_DSN` is now a transitional fallback, not a permanent equal option), `HTTP_ADDR`, `AUTH_JWT_ISSUER` = `https://auth.loginid-takehome.internal` / `AUTH_JWT_AUDIENCE` = `loginid-api-service` (literal values per `handoff-03-auth.md` — not secrets, safe to ship in config), `CONNECTOR_JWT_AUDIENCE` = `idp-connector-service` (the connector's own inbound audience, distinct from `AUTH_JWT_AUDIENCE` — `handoff-03-auth.md` v2 / `connector-security.md` §5, consistency-pass F15), `CONNECTOR_CLIENT_ID`/`CONNECTOR_CLIENT_SECRET` (api-service's own client credential for obtaining a `connector:identity-lookup`-scoped token to call idp-connector on a caller's behalf — a secret, added to `handoff-04-secrets.md`'s inventory as row 10 per F15), `APP_MODE` = `verifier` (default) | `issuer` (selects which of `api-service`'s two Kubernetes Deployments a given pod is — the issuer Deployment alone mounts the JWT signing-key Secret, per 02/04's F13 fix in `handoff-04-secrets.md`/`containerization-design.md`; confirmed spelling from Theo), `IDP_ABC_BASE_URL`, `IDP_ABC_CLIENT_ID`/`IDP_ABC_CLIENT_SECRET`. No flags, no config files.
=== containerization issuer Service + reachability ===
71: name: api-service-issuer
76: matchLabels: {app: api-service-issuer}
79: labels: {app: api-service-issuer}
81: serviceAccountName: api-service-issuer # the ONLY SA that mounts the JWT signing key, per handoff-04-secrets.md row #2 / F13's fix
109:metadata: {name: api-service-issuer}
111: selector: {app: api-service-issuer}
115:**Reachability:** the verifying `api-service` Deployment fetches the JWKS from `api-service-issuer`'s in-cluster `Service` (`https://api-service-issuer.loginid-poc.svc/jwks`) at startup and on rotation, per row #3's "fetched from the issuer" option — no config variable or NetworkPolicy rule exis{
"command": "cd /Users/warrenthompson/Source/LoginID\necho \"=== multi-db head 1-16 ===\"; sed -n '1,16p' 05-data-ops/multi-db-strategy.md | cut -c1-600\necho; echo \"=== :225 area (identity resolution) ===\"; grep -n \"decides identity\\|Resolving an \\`/identity\\` payload\" 05-data-ops/multi-db-strategy.md | cut -c1-600\necho; echo \"=== 03/PLAN:23 ===\"; sed -n '23p' 03-engineering-delivery/PLAN.md | cut -c1-500\necho; echo \"=== go-layout 44-48 ===\"; sed -n '44,48p' 03-engineering-delivery/decisions/go-layout-debate.md\necho; echo \"=== pii-governance :35 :40 :76-77 ===\"; sed -n '35p;40p' 05-data-ops/pii-governance.md | cut -c1-1200; grep -n \"DeleteExpired\\|DeleteProfile\" 05-data-ops/pii-governance.md | cut -c1-700\necho; echo \"=== observability 41-45 ===\"; sed -n '41,45p' 04-infra-devops/observability.md | cut -c1-1200\necho; echo \"=== 04 backlog Story 6 refs ===\"; grep -n \"audit-log stream\\|deletion_log\\|retention row\" 04-infra-devops/backlog.md | cut -c1-300",
"description": "Read the fixed seam 5 and seam 9 sections"
}=== multi-db head 1-16 === # Multi-Database Abstraction Strategy Owner: 05-data-ops. Implemented by: `03-engineering-delivery`, from the prose contract below — this project is still in planning phase, so the interface and schema exist here as design, not as `.go`/`.sql` files, until there's an explicit go-ahead to write code. Full research trail: `research-data-ops-best-practices.md`. ## The interface contract (prose, not code) > **This section's interface listing has been removed, not corrected.** It restated the repository methods, the domain types and the error sentinel, and every one of those restatements had drifted from the authoritative version: `Update` was described as a portable upsert (it never creates — `Upsert` is a separate method), phone search as exact/prefix (exact only, per finding F9 with 01), pagination as bare limit/offset (there are `Limit` 1..100 and `Offset` 0..10 000 ceilings, enforced with `ErrInvalidQuery`), and `ErrNotFound` as the only sentinel (there are seven). > > **The authoritative definitions are below, in "The Go-shaped contract":** §1 the factory and composite, §2 domain types, §3 the interfaces, §3b–§3c the cross-table operations, §4 error semantics, §5 field-level schema. > > I first tried to fix this by striking through the wrong claims inline. That was the wrong instinct — a corrected restatement is still a restatement, and it would have drifted again the next time the contract moved. Deleting it removes the failure mode rather than patching this instance of it. Found by this track's own seam-13 sweep and finding F20; the reasoning that follows is unaffected and still stands. Two concrete implementations satisfy all three interfaces: - `postgres` package — serves both PostgreSQL and CockroachDB from one implementation. They share enough of the wire protocol and SQL surface that a second implementation would be duplicated effort — **but that is only half the argument, and the half that was originally stated here was the cheap half.** Sourced in `research-cockroachdb-postgres-semantics.md`: CockroachDB defaults to SERIALIZABLE and returns client-retryable `40001` errors that PostgreSQL at READ COMMITTED never emits, and constraint-violation error *text* is not portable between them. One package serves both **only if it carri - `sqlite` package — a separate implementation. SQLite diverges enough (no native UUID/BOOLEAN, no trigram index, app-side ID generation) that forcing it through the Postgres implementation would mean littering that code with backend conditionals, which is the exact failure mode a repository-per-backend split is meant to avoid. === :225 area (identity resolution) === 225:**`Upsert` conflicts on `id` only, and the caller must already hold that `id`.** It exists for re-hydrating a row already known to exist — not for resolving an unknown one. A cold read caught the circularity in the original justification: the IDP path "does not know whether the row exists" and therefore does not know its `id` either, so `Upsert` could not have served the caller it was justified with. **Resolving an `/identity` payload to an existing profile row is not the DAO's problem** — there is deliberately no unique key on `phone` or `name` to match on, because neither is unique in re === 03/PLAN:23 === | S4. Connector `/auth` + `/identity` | Go client + handlers per the assignment's fixed JSON contracts | 02's `connector-security.md` (token lifecycle: no-cache-by-default, fetch per `/identity` call, discard on return — TTL/refresh apply only to how quickly a re-fetch happens, not to any stored token) | none outbound | Token-lifecycle logic matches 02's spec exactly; endpoint paths/bodies are the assignment's, not re-designed | === go-layout 44-48 === │ - auth middleware ─────────┼──┐ │ - POST /identity (client) │ │ - handlers (search/get │ │ │ - token lifecycle │ │ user_profile) │ │ │ (no-cache-by-default, │ │ │ │ │ │ fetch-per-call, │ │ ▼ │ │ │ per 02 connector- │ === pii-governance :35 :40 :76-77 === | **Audit-log stream** (the emitted log entries evidencing deletions, as read by 04's sink) | the entry's own timestamp | **Proposed, pending ruling — see basis** | **unknown — needs a legal ruling.** The audit stream evidences that a deletion happened, which is useful for exactly as long as someone may ask. That period is a question about limitation windows and regulator expectations, not an engineering one, and no engineering rationale substitutes for it | Expiry at the sink | Nothing | unknown — needs product/legal ruling | **Proposed** | **The `deletion_log` table itself has no sweeper, and that is a named gap rather than an omission.** It is not in the table above because it would be a row nobody can act on. Two facts make it awkward in a way worth stating rather than tidying away: 43:2. **No method sweeps it.** `DeleteExpired` takes a `RetentionClass`, and there is no class for it. If a window is ruled, implementing it is a **contract change** — either a fourth class or a separate method — not a configuration value someone can set. Flagging that now so the cost is visible at the time of the ruling rather than discovered after it. 76:- **`DeleteExpired(ctx, class, olderThan, maxRows) (SweepResult, error)`** — one retention class per call, using that class's own clock column, cascading to credentials and writing a `deletion_log` row in the same transaction. **Batched: `maxRows` is required (1..10 000) and each batch is its own transaction**, so an interrupted sweep leaves fewer rows deleted rather than a half-written batch. The caller loops until `SweepResult.Drained` is true. It must not be called with a request-scoped context — a sweep cancelled because an inbound request went away is a retention policy silently dependent on request lifetimes. 77:- **`DeleteProfile(ctx, id, externalRef)`** for subject requests, on the same code path. **`reason` is not a parameter** — the method asserts `subject_request` internally, as `DeleteExpired` asserts `retention_sweep`. A caller-supplied reason code would let a copy-paste error mislabel an audit row, which destroys the one property the log exists to have: that its reasons are trustworthy. === observability 41-45 === ## Log-sink retention: the deletion-gap enforcement site Application logs, metrics, and traces have no `source` column, no clock tied to a `user_profile` row, and no key a subject-deletion request could reach by cascade — a deleted profile's data can still exist in a log line indefinitely. 05's never-log list (`handoff-04-secrets.md`) and this document's enforcement mechanism (above) are the upstream control — if PII never enters a log, there's nothing in a log to expire — but that only holds for the fields on the list. What's left (chiefly the audit trail: `sub`, scope, opaque record id, timestamp — permitted by design, pseudonymous rather than PII-free) still needs a retention window: `pii-governance.md` § "Retention windows (S5)", the "Audit-log stream" row (clock: the entry's own timestamp; window: proposed, pending a legal ruling; disposal: **expiry at the sink**) names this document's log-sink retention policy as its enforcement site — the citation resolves in both directions. Not a deletion job 04 writes — a retention setting on infrastructure 04 already operates. **Not the same object as the `deletion_log` table.** 05's `deletion_log` (rows written transactionally by `DeleteExpired`/`DeleteProfile`) is a separate thing from the audit-log stream above — it's PII-free by construction, carries no `RetentionClass`, and isn't in 05's retention table at all; nothing here applies to it, and no metric from a `SweepResult` does either. If a retention window is ever wanted for that table, it's a DAO contract change (05's to make), not a sink-retention config value (04's). === 04 backlog Story 6 refs === 110:- [ ] The audit-log stream expires no later than 05's stated window — enforcement site: the log sink's retention configuration (a Loki `retention_period` or equivalent for the chosen sink), not application code.
{
"command": "cd /Users/warrenthompson/Source/LoginID\nF='--include=*.md'\nX=\"grep -v ^./.git | grep -v decisions/cross-track\"\necho \"=== dao.New restatements ===\"; grep -rn \"dao.New\\|func New(\" $F . | eval $X | cut -c1-200\necho; echo \"=== DeleteExpired / DeleteProfile signatures ===\"; grep -rn \"DeleteExpired(\\|DeleteProfile(\" $F . | eval $X | cut -c1-260\necho; echo \"=== Search signature restatements ===\"; grep -rn \"Search(ctx\" $F . | eval $X | cut -c1-220\necho; echo \"=== sentinel lists (ErrDuplicate) ===\"; grep -rln \"ErrDuplicateUsername\" $F . | eval $X; grep -rn \"ErrDuplicate\\b\\|ErrInvalidInput\" $F . | eval $X | cut -c1-200\necho; echo \"=== version labels ===\"; grep -rn \"handoff-03-auth.md\\` v[0-9]\\|handoff-03-auth.md v[0-9]\\|handoff-04-secrets.md\\` v[0-9]\\|handoff-04-secrets.md v[0-9]\" $F . | eval $X | cut -c1-150\necho; echo \"=== retention numbers ===\"; grep -rn \"24 months\\|24mo\\|30 days\\|30d\\|7 days\\|7d \" $F . | eval $X | grep -v \"research-\" | cut -c1-180\necho; echo \"=== retention class names ===\"; grep -rn \"idp_cache_orphan\\|class=\\\"\\|orphan\\`\\|\\`orphan\" $F . | eval $X | cut -c1-200\necho; echo \"=== config vars: which docs list them ===\"; for v in DB_DRIVER DB_DSN_FILE HTTP_ADDR AUTH_JWT_ISSUER AUTH_JWT_AUDIENCE CONNECTOR_JWT_AUDIENCE CONNECTOR_CLIENT_ID APP_MODE IDP_ABC_BASE_URL IDP_ABC_CLIENT_SECRET; do echo \"$v: $(grep -rl \"$v\" $F . | eval $X | tr '\\n' ' ')\"; done\necho; echo \"=== issuer/audience literals ===\"; grep -rn \"auth.loginid-takehome.internal\\|loginid-api-service\\|idp-connector-service\" $F . | eval $X | cut -c1-160\necho; echo \"=== composite restatements ===\"; grep -rn \"Profiles()\" $F . | eval $X | cut -c1-200\necho; echo \"=== Limit/ceiling restatements ===\"; grep -rn \"hard ceiling\\|Max page\\|1\\.\\.100\\|default 50\\|default page 20\\|Default page size 20\" $F . | eval $X | cut -c1-200\necho; echo \"=== table shapes: user_credential columns restated ===\"; grep -rn \"secret_state\" $F . | eval $X | cut -c1-120 | awk -F: '{print $1}' | sort | uniq -c\necho; echo \"=== connector aud/scope literals ===\"; grep -rn \"connector:identity-lookup\" $F . | eval $X | awk -F: '{print $1}' | sort | uniq -c\necho; echo \"=== X-Vendor-Access-Token ===\"; grep -rn \"X-Vendor-Access-Token\\|vendor token travels\\|presents it as a header\" $F . | eval $X | cut -c1-160",
"description": "Seam 13 restatement greps across the repository"
}=== dao.New restatements ===
PLANNING.md:15:| 05 Data Ops | Priya Nandakumar | **plan approved**; backlog authored → Epic `LT-5`, transcribing | — | — | `05-data-ops/PLAN.md`; 6 stories S1–S6, S1 = Go-shaped contract in `multi-db
PLANNING.md:47:- **DAO driver-selection mechanism:** a factory `dao.New(driver, dsn string) (dao.Repository, error)` chosen at startup from `DB_DRIVER`. `dao.Repository` is a composite interface — `Pr
PLANNING.md:48:- **Stable for 04 to build against:** the two-binary split, the `cmd/` entrypoint names, the env-var config approach and variable names above, and the `dao.New(driver, dsn)` factory sig
05-data-ops/backlog.md:52:**Description.** Turn the prose strategy in `multi-db-strategy.md` into a contract 03 can implement without re-deriving this track's reasoning: interface signatures, a per-me
05-data-ops/multi-db-strategy.md:87:Agreed directly with Renata Cole (03) on 2026-09-12. **Her `dao.New` signature is unchanged**; the composite is the resolution of what had been a silent mismatch be
05-data-ops/multi-db-strategy.md:90:func New(driver, dsn string) (Repository, error) // driver: "postgres" | "cockroachdb" | "sqlite"
05-data-ops/PLAN.md:28:- **Alignment note to 03's published factory**: `../PLANNING.md` service boundaries names `dao.New(driver, dsn string) (dao.Repository, error)` — a single `Repository` return ty
05-data-ops/PLAN.md:151:| 03 | Service boundaries in `../PLANNING.md` — already published | Consumed now. The `dao.New(driver, dsn) (dao.Repository, error)` factory versus this track's three interface
03-engineering-delivery/backlog.md:28:**Description:** Implement the interface layer from `05-data-ops/multi-db-strategy.md`'s Go-shaped contract (§1–§4): the `Repository` composite (`Profiles()`, `Cr
03-engineering-delivery/PLAN.md:122:- **DAO driver-selection mechanism:** a factory `dao.New(driver, dsn string) (dao.Repository, error)`
03-engineering-delivery/PLAN.md:127: env-var config approach and the variable names above, and the `dao.New(driver, dsn)` factory
=== DeleteExpired / DeleteProfile signatures ===
05-data-ops/pii-governance.md:76:- **`DeleteExpired(ctx, class, olderThan, maxRows) (SweepResult, error)`** — one retention class per call, using that class's own clock column, cascading to credentials and writing a `deletion_log` row in the same transaction.
05-data-ops/pii-governance.md:77:- **`DeleteProfile(ctx, id, externalRef)`** for subject requests, on the same code path. **`reason` is not a parameter** — the method asserts `subject_request` internally, as `DeleteExpired` asserts `retention_sweep`. A caller-
05-data-ops/multi-db-strategy.md:103: DeleteExpired(ctx context.Context, class RetentionClass, olderThan time.Time, maxRows int) (SweepResult, error)
05-data-ops/multi-db-strategy.md:104: DeleteProfile(ctx context.Context, id string, externalRef *string) error
05-data-ops/multi-db-strategy.md:270:**`DeleteExpired(ctx, class, olderThan, maxRows)`** — one class per call, never a mixed-class predicate, because a single predicate spanning two clocks is how the wrong window gets applied to the wrong rows.
05-data-ops/multi-db-strategy.md:278:**`DeleteProfile(ctx, id, externalRef)`** — subject-deletion. Writes the `deletion_log` row in the same transaction, with **`reason` set internally to `subject_request`; it is not a parameter.**
05-data-ops/backlog.md:127:**Description.** The cross-track consistency pass (F21) found that `pii-governance.md` specified a bulk retention sweep and a subject-deletion request sharing one code path, each writing a `deletion_log` row, and `multi-db-strategy.m
03-engineering-delivery/backlog.md:191:**Description:** Consistency-pass finding F21 identified that 05's retention policy (`pii-governance.md`) had no reachable DAO method, and no track owned writing the sweep job itself. 05's contract addendum (`multi-db-str
=== Search signature restatements ===
05-data-ops/multi-db-strategy.md:167: Search(ctx context.Context, q ProfileQuery) (results []UserProfile, total int, err error)
05-data-ops/PLAN.md:9:The first-wave design work already exists here as prose — `research-data-ops-best-practices.md` (sourced research), `multi-db-strategy.md` (interface contract and schema shape), `pii-governance.md`
05-data-ops/decisions/search-query-shape.md:23:Search(ctx context.Context, q ProfileQuery) (results []UserProfile, total int, err error)
=== sentinel lists (ErrDuplicate) ===
05-data-ops/research-cockroachdb-postgres-semantics.md
05-data-ops/multi-db-strategy.md
05-data-ops/PLAN.md
03-engineering-delivery/decisions/error-semantics.md
02-ai-security-architecture/handoff-04-secrets.md
03-engineering-delivery/decisions/error-semantics.md:7:Go 1.13+ error wrapping (`fmt.Errorf("...: %w", err)`) for propagating unexpected/lower-level errors up the call stack, plus a small set of expor
03-engineering-delivery/decisions/error-semantics.md:13:3. **`errors.Is` against a sentinel breaks silently if a backend returns a driver-native error instead** (e.g. `*pgconn.PgError` instead of `dao
03-engineering-delivery/decisions/error-semantics.md:20:1. Sentinel set is closed and fixed now: `dao.ErrNotFound`, `dao.ErrDuplicate`, `dao.ErrInvalidInput`. Defined once in `internal/dao`. Rule: no
03-engineering-delivery/decisions/error-semantics.md:22:3. The `Repository` interface contract states translation of known driver-native errors to the shared sentinels is **mandatory**, not optional,
03-engineering-delivery/decisions/error-semantics.md:33:- **Replaced:** the invented sentinel set (`ErrNotFound`/`ErrDuplicate`/`ErrInvalidInput`) is superseded by 05's actual seven: `ErrNotFound`, `E
=== version labels ===
PLANNING.md:13:| 03 Engineering & Delivery | Renata Cole | plan approved — re-run findings closed | team-lead transcription (Epic `LT-3`); Warren's im
PLANNING.md:46:- **Config surface:** environment variables only (12-factor: config lives in the environment, not in checked-in files) — `DB_DRIVER` (p
PLAN.md:133:2. **Authorization scopes and their semantics** — `02/api-auth-design.md` (post-S4, post-S8) vs. `02/handoff-03-auth.md` v2 vs. `01/person
PLAN.md:134:3. **Config surface and secrets** — `PLANNING.md` Service boundaries vs. `02/handoff-04-secrets.md` v1 vs. `04/secrets-delivery.md` vs. `0
LOG.md:55:**Phase 3 — 02 closes S5 and S8.** S5 (structured written debate on the AI-workflow narrative's core claim; `decisions/ai-workflow-claim-deb
LOG.md:71:**Pass fixes — 02 closed; all 52 findings addressed.** Marcus fixed 16 in place without reopening a debate. The design answers: **F3** — our
LOG.md:75:**Re-run round closed by all five tracks; third pass launched.** 02 rewrote `connector-security.md` §1/§2 and attack-tree row 3 to the calle
03-engineering-delivery/CLAUDE.md:36:Planning complete (2026-09-12): 4 hires spawned and live, `PLAN.md` approved, Service boundaries published in roo
03-engineering-delivery/backlog.md:98:**Description:** The client-facing handlers in `internal/api` implementing the assignment's Q2 (search and retri
03-engineering-delivery/backlog.md:101:- [ ] Implements `handoff-03-auth.md` v3's scope vocabulary and pagination/rate-limit numbers without re-derivi
03-engineering-delivery/backlog.md:102:- [ ] `authorize()` runs as an explicit call inside each handler after request parsing, before the DAO call — e
03-engineering-delivery/backlog.md:103:- [ ] `profile:search` responses carry masked PII fields by default (e.g. phone as `***-***-1234`, address redu
03-engineering-delivery/backlog.md:106:- [ ] A `profile:search` query is rejected outright, not truncated, if it would exceed the result cap unconstra
03-engineering-delivery/backlog.md:111:**Depends on:** S2; **02: Auth scheme and authorization hand-off** (`handoff-03-auth.md` v3, accepted).
03-engineering-delivery/backlog.md:120:**Description:** The middleware at the `cmd/api-service` edge validating the client-credentials JWT scheme from
03-engineering-delivery/backlog.md:126:- [ ] **Pins the expected signing algorithm and rejects any token whose header claims a different one**, includ
03-engineering-delivery/backlog.md:127:- [ ] The token-issuing endpoint (the `APP_MODE=issuer` mode) has its own failed-grant rate limiting and progre
03-engineering-delivery/backlog.md:131:**Depends on:** S1; **02: Auth scheme and authorization hand-off** (`handoff-03-auth.md` v3, accepted); **04: `
03-engineering-delivery/backlog.md:140:**Description:** `cmd/idp-connector`'s outbound client and inbound handlers for the assignment's fixed `/auth`
03-engineering-delivery/backlog.md:149:**Depends on:** S1; **02: Connector security spec** (`connector-security.md`, accepted); **02: Auth scheme and
02-ai-security-architecture/CLAUDE.md:51:Phase 2 (execution): all four hires spawned and live as named teammates in this session. Completed: **S7** —
02-ai-security-architecture/backlog.md:175:- [x] The two findings affecting 03's implementation (decomposition-counter scope, connector inbound-auth r
02-ai-security-architecture/backlog.md:270:**Depends on:** `handoff-04-secrets.md` v2 (this track); 03's `APP_MODE` config surface addition; 04's mani
04-infra-devops/secrets-delivery.md:3:Design-only. This document implements the delivery mechanism for what `../02-ai-security-architecture/handoff-04
04-infra-devops/secrets-delivery.md:37:*AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2. Bree's (capa
04-infra-devops/containerization-design.md:5:Inputs this depends on: `../PLANNING.md` Service boundaries (03, stable), `../02-ai-security-architecture
04-infra-devops/backlog.md:33:- [ ] 02 (the hand-off's author) confirms this implementation matches `handoff-04-secrets.md` v2's inventory without nee
04-infra-devops/observability.md:52:*AI tooling note: drafted directly by Theo (Sonnet, this session) from `handoff-04-secrets.md` v2's never-log list
04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md:8:2. `../02-ai-security-architecture/handoff-04-secrets.md` v1 (owned by 02/Marcus I
04-infra-devops/decisions/newcomer-coldread-boundaries-secrets.md:43:04 proceeds on `handoff-04-secrets.md` v2, still gated on 05's migration approach
=== retention numbers ===
05-data-ops/pii-governance.md:32:| `user_profile`, `source='direct'` | `updated_at` | **24 months** since last update | *unknown — needs product/legal ruling.* Engineering basis on
05-data-ops/pii-governance.md:33:| `user_profile`, `source='idp_cache'` | `updated_at` | **30 days** since last hydration | Engineering: **a cache is not a record.** Re-hydration c
05-data-ops/pii-governance.md:34:| `user_profile`, `source='idp_cache'`, **no referencing `user_credential`** (orphaned cache) | **`created_at`**, deliberately not `updated_at` | *
05-data-ops/backlog.md:90:**Description.** `pii-governance.md` previously stated the policy shape and stopped at "a product/legal decision this track doesn't own outright" — true,
02-ai-security-architecture/api-auth-design.md:67:- **90-day re-approval** on every scope grant (not `profile:search` alone) is a backstop, not the only mechanism. Grants are revok
=== retention class names ===
05-data-ops/multi-db-strategy.md:256:type RetentionClass string // "direct" | "idp_cache" | "idp_cache_orphan"
05-data-ops/multi-db-strategy.md:274:**`DeleteExpired` must not be called with a request-scoped context.** It is a background operation governed by the job's own timeout; a sweep cancelled halfway bec
04-infra-devops/observability.md:37:Mechanism: the sweep job (whatever invokes it — a CronJob against the lab cluster, per 05's mechanism-agnostic requirement) emits these as Prometheus metrics with a
=== config vars: which docs list them ===
DB_DRIVER: PLANNING.md 03-engineering-delivery/PLAN.md 03-engineering-delivery/backlog.md 03-engineering-delivery/decisions/go-layout-debate.md 04-infra-devops/local-dev-loop.md 04-infra-devops/containerization-design.md 04-infra-devops/backlog.md
DB_DSN_FILE: PLANNING.md PLAN.md LOG.md 03-engineering-delivery/PLAN.md 03-engineering-delivery/backlog.md 03-engineering-delivery/decisions/go-layout-debate.md 02-ai-security-architecture/backlog.md 02-ai-security-architecture/handoff-04-secrets.md 02-ai-security-architecture/handoff-03-auth.md 04-infra-devops/secrets-delivery.md 04-infra-devops/local-dev-loop.md 04-infra-devops/containerization-design.md
HTTP_ADDR: PLANNING.md 03-engineering-delivery/PLAN.md 03-engineering-delivery/backlog.md 03-engineering-delivery/decisions/go-layout-debate.md 04-infra-devops/local-dev-loop.md 04-infra-devops/containerization-design.md
AUTH_JWT_ISSUER: PLANNING.md 03-engineering-delivery/PLAN.md 03-engineering-delivery/backlog.md 03-engineering-delivery/decisions/go-layout-debate.md 02-ai-security-architecture/handoff-04-secrets.md 02-ai-security-architecture/handoff-03-auth.md 04-infra-devops/containerization-design.md
AUTH_JWT_AUDIENCE: PLANNING.md 03-engineering-delivery/PLAN.md 03-engineering-delivery/backlog.md 03-engineering-delivery/decisions/go-layout-debate.md 02-ai-security-architecture/handoff-04-secrets.md 02-ai-security-architecture/handoff-03-auth.md
CONNECTOR_JWT_AUDIENCE: PLANNING.md
CONNECTOR_CLIENT_ID: PLANNING.md 03-engineering-delivery/backlog.md
APP_MODE: PLANNING.md LOG.md 03-engineering-delivery/backlog.md 02-ai-security-architecture/handoff-04-secrets.md 02-ai-security-architecture/backlog.md 02-ai-security-architecture/handoff-03-auth.md 04-infra-devops/secrets-delivery.md 04-infra-devops/containerization-design.md 04-infra-devops/backlog.md
IDP_ABC_BASE_URL: PLANNING.md 03-engineering-delivery/PLAN.md 03-engineering-delivery/decisions/go-layout-debate.md 04-infra-devops/local-dev-loop.md
IDP_ABC_CLIENT_SECRET: PLANNING.md 03-engineering-delivery/PLAN.md 02-ai-security-architecture/handoff-04-secrets.md
=== issuer/audience literals ===
PLANNING.md:46:- **Config surface:** environment variables only (12-factor: config lives in the environment, not in checked-in files) — `DB_DRIVER` (postgres|co
03-engineering-delivery/backlog.md:140:**Description:** `cmd/idp-connector`'s outbound client and inbound handlers for the assignment's fixed `/auth` and `/iden
03-engineering-delivery/backlog.md:146:- [ ] The connector's own `/auth`/`/identity` handlers validate an inbound JWT with `aud: idp-connector-service` and `sco
03-engineering-delivery/decisions/go-layout-debate.md:32: │ HTTPS + bearer token (JWT, iss=https://auth.loginid-takehome.internal,
03-engineering-delivery/decisions/go-layout-debate.md:33: │ aud=loginid-api-service)
02-ai-security-architecture/connector-security.md:52:- **Callers of `cmd/idp-connector`'s own `/auth` and `/identity` must be authenticated**, using the same OA
02-ai-security-architecture/handoff-04-secrets.md:26:| 10 | `api-service`'s own client credential (`client_id`/`client_secret`) toward the issuer, scoped `conne
02-ai-security-architecture/backlog.md:127:- [x] `idp-connector`'s own `/auth`/`/identity` require authentication with a distinct audience/scope — enforcement s
02-ai-security-architecture/handoff-03-auth.md:10:- **Literal values, filling the "pending 02's final scheme" placeholder in your Service boundaries config surf
02-ai-security-architecture/handoff-03-auth.md:19:- **Middleware at the API edge**, in `internal/api/` per your published service boundary — every request to `c
02-ai-security-architecture/decisions/second-review-security-docs.md:32:1. **No authentication requirement on calls into the connector's own `/auth`/`/identity`
=== composite restatements ===
PLANNING.md:15:| 05 Data Ops | Priya Nandakumar | **plan approved**; backlog authored → Epic `LT-5`, transcribing | — | — | `05-data-ops/PLAN.md`; 6 stories S1–S6, S1 = Go-shaped contract in `multi-db
PLANNING.md:47:- **DAO driver-selection mechanism:** a factory `dao.New(driver, dsn string) (dao.Repository, error)` chosen at startup from `DB_DRIVER`. `dao.Repository` is a composite interface — `Pr
LOG.md:43:**Phase 1 complete — fifth plan approved (05).** Priya's plan (session `data-ops-planning-phase`, Opus): six stories with the Go-shaped contract in `multi-db-strategy.md` as the critical-pat
05-data-ops/backlog.md:52:**Description.** Turn the prose strategy in `multi-db-strategy.md` into a contract 03 can implement without re-deriving this track's reasoning: interface signatures, a per-me
05-data-ops/backlog.md:127:**Description.** The cross-track consistency pass (F21) found that `pii-governance.md` specified a bulk retention sweep and a subject-deletion request sharing one code path,
05-data-ops/backlog.md:134:- [x] `Profiles().Delete` deliberately still writes no log row, with the reason recorded.
05-data-ops/multi-db-strategy.md:93: Profiles() ProfileRepository
05-data-ops/multi-db-strategy.md:251:`pii-governance.md` specifies a bulk retention sweep and a subject-deletion request sharing **the same code path**, each writing a `deletion_log` row, and §5 of th
05-data-ops/multi-db-strategy.md:282:**This supersedes `Profiles().Delete` for anything governed by retention.** `Profiles().Delete(ctx, id)` remains for ordinary application deletes and still writes
05-data-ops/multi-db-strategy.md:292:| `Profiles().Get` | yes (lookup key) | — | — | `ErrNotFound` |
05-data-ops/multi-db-strategy.md:293:| `Profiles().Search` | — | — | — | `ErrInvalidQuery` |
05-data-ops/multi-db-strategy.md:294:| `Profiles().Create` | **no** — must be `""`, DAO generates | DAO sets both | — | `ErrInvalidArgument` (non-empty ID, or CHECK violation); `ErrAlreadyExists` — se
05-data-ops/multi-db-strategy.md:295:| `Profiles().Update` | yes — identifies the row | DAO sets `UpdatedAt` | **full replace**; nil NULLs the column | `ErrNotFound`, `ErrInvalidArgument` |
05-data-ops/multi-db-strategy.md:296:| `Profiles().Upsert` | **yes** — conflict target; caller must already hold it | DAO sets `UpdatedAt`; sets `CreatedAt` on insert only — **the `DO UPDATE SET` list
05-data-ops/multi-db-strategy.md:297:| `Profiles().Delete` | yes | — | — | `ErrNotFound`; **cascades to credentials, see §6.2** |
05-data-ops/PLAN.md:28:- **Alignment note to 03's published factory**: `../PLANNING.md` service boundaries names `dao.New(driver, dsn string) (dao.Repository, error)` — a single `Repository` return ty
03-engineering-delivery/backlog.md:28:**Description:** Implement the interface layer from `05-data-ops/multi-db-strategy.md`'s Go-shaped contract (§1–§4): the `Repository` composite (`Profiles()`, `Cr
03-engineering-delivery/backlog.md:198:- [ ] `DeleteProfile` is the only path used for a subject-deletion request; ordinary application deletes still go through `Profiles().Delete` (no log row), and t
03-engineering-delivery/decisions/go-layout-debate.md:51:│ Profiles() │ │ └────┼─────────────────────────┘
=== Limit/ceiling restatements ===
05-data-ops/multi-db-strategy.md:7:> **This section's interface listing has been removed, not corrected.** It restated the repository methods, the domain types and the error sentinel, and every one of
05-data-ops/multi-db-strategy.md:193: Limit int // 1..100; 0 means "use default 50", deliberately
05-data-ops/multi-db-strategy.md:314:2. **`Limit == 0` means "default 50", and requesting zero rows is out of scope.** The review correctly noted this is the same zero-value trap the pointer-means-abs
05-data-ops/decisions/search-query-shape.md:19: Limit int // 1..100; 0 means "use default 50", deliberately
05-data-ops/decisions/search-query-shape.md:36:- **Pagination.** Limit/offset. Max page 100, default 50, offset ceiling 10 000. `Limit < 0` or `> 100`, and `Offset < 0` or `> 10_000`, return `ErrInval
03-engineering-delivery/backlog.md:104:- [ ] Cursor-based pagination only, default page size 20, hard ceiling 50 — enforcement site: the pagination parameter parsing, rejecting any offset-based parame
02-ai-security-architecture/api-auth-design.md:59:- **Opaque cursor at the API boundary** (reason
… truncated (20 KB cap){
"command": "cd /Users/warrenthompson/Source/LoginID\necho \"=== multi-db :225 tail ===\"; sed -n '225p' 05-data-ops/multi-db-strategy.md | cut -c560-1100\necho; echo \"=== go-layout 60-68 + any banner ===\"; sed -n '60,68p' 03-engineering-delivery/decisions/go-layout-debate.md; grep -n \"snapshot\\|predates\\|as of\\|Phase 1 plan\\|historical\" 03-engineering-delivery/decisions/go-layout-debate.md 03-engineering-delivery/PLAN.md | cut -c1-220\necho; echo \"=== 03/PLAN config + layout ===\"; sed -n '96,115p' 03-engineering-delivery/PLAN.md | cut -c1-200\necho; echo \"=== 03/backlog :28 :104 ===\"; sed -n '28p;104p' 03-engineering-delivery/backlog.md | cut -c1-700\necho; echo \"=== 05/backlog :127 ===\"; sed -n '127p' 05-data-ops/backlog.md | cut -c1-900\necho; echo \"=== observability :37 orphan ===\"; sed -n '37p' 04-infra-devops/observability.md | grep -o \"class\\` label[^.]*\\.\" \necho; echo \"=== secret_state restatements ===\"; grep -n \"secret_state\" 03-engineering-delivery/backlog.md 05-data-ops/backlog.md | cut -c1-260\necho; echo \"=== 03/CLAUDE:36 ===\"; sed -n '36p' 03-engineering-delivery/CLAUDE.md | cut -c1-600\necho; echo \"=== pii-gov :35 names 04 doc? ===\"; sed -n '35p' 05-data-ops/pii-governance.md | grep -o \"observability[^|]*\" | head -2; sed -n '36,45p' 05-data-ops/pii-governance.md | cut -c1-400",
"description": "Fetch remaining restatement sites for seam 13"
}=== multi-db :225 tail ===
h on, because neither is unique in reality. It belongs to **`api-service`**, which is the party that both decides identity and holds a DAO handle: 03's layout gives `idp-connector` no database access at all (`../03-engineering-delivery/decisions/go-layout-debate.md`), so the connector fetches the payload and its caller decides what the payload *is* and then calls `Create` or `Update`. An earlier version of this sentence said "the connector" and was wrong about which process can reach the database (finding F22).
=== go-layout 60-68 + any banner ===
└──────────────┬────────────────┘
│
▼
internal/config ◄── shared by BOTH binaries
env-var loading only: DB_DRIVER, DB_DSN, DB_DSN_FILE,
HTTP_ADDR, AUTH_JWT_ISSUER, AUTH_JWT_AUDIENCE,
IDP_ABC_BASE_URL, IDP_ABC_CLIENT_ID/SECRET
--------------------------------------------------------------------------------
03-engineering-delivery/decisions/go-layout-debate.md:107:Note: the diagram predates the ruling's addition of `internal/app` (shared bootstrap) below — that package sits alongside `internal/config` as a second package bo
03-engineering-delivery/PLAN.md:97:*This subsection is the Phase 1 snapshot (2026-09-12, before hiring). Root `PLANNING.md`'s
03-engineering-delivery/PLAN.md:102:`PLANNING.md`, not this snapshot, for what 04 or anyone else should build against.*
=== 03/PLAN config + layout ===
*This subsection is the Phase 1 snapshot (2026-09-12, before hiring). Root `PLANNING.md`'s
Service boundaries section is the current, authoritative version — it has since gained
`internal/app` (shared bootstrap, `decisions/go-layout-debate.md`), `DB_DSN_FILE` (02's
`handoff-04-secrets.md` S7), literal `AUTH_JWT_ISSUER`/`AUTH_JWT_AUDIENCE` values, and the
connector's own audience/client-credential variables (consistency-pass F15). Consult
`PLANNING.md`, not this snapshot, for what 04 or anyone else should build against.*
- **Two binaries**, not one: `cmd/api-service` (Q1 DAO + Q2 REST API, client-facing, bearer-
token auth) and `cmd/idp-connector` (Q3, outbound-only to vendors). Reasoning: different
threat models and auth surfaces per 02's track, independent scaling/deploy needs, and it
mirrors the assignment's own Q2/Q3 split rather than inventing a new boundary.
- **Package layout:**
```
cmd/api-service/main.go
cmd/idp-connector/main.go
internal/dao/ # Repository interface + postgres/cockroachdb/sqlite implementations
internal/model/ # shared structs: UserProfile, UserCredential
internal/api/ # REST handlers, router, auth middleware
internal/connector/ # /auth, /identity client + handlers
=== 03/backlog :28 :104 ===
**Description:** Implement the interface layer from `05-data-ops/multi-db-strategy.md`'s Go-shaped contract (§1–§4): the `Repository` composite (`Profiles()`, `Credentials()`, `Methods()`, `Close()`), the three sub-interfaces, the domain structs (`UserProfile`, `UserCredential`, `AuthMethod`, `ProfileQuery`), the seven sentinel errors, and the `dao.New(driver, dsn)` factory signature. No backend logic yet — this is the shape both backend packages implement against, plus the fakes S9's tests use per `decisions/test-double-strategy.md`.
- [ ] Cursor-based pagination only, default page size 20, hard ceiling 50 — enforcement site: the pagination parameter parsing, rejecting any offset-based parameter. **The handler's 20/50 is the authoritative clamp, enforced before the DAO is ever called** — the DAO's own `Limit` bound (1..100, default 50 per `multi-db-strategy.md`) is a second, independent backstop only reachable if the handler clamp is bypassed, not a second policy a caller can rely on (consistency-pass F10: no document previously said which of the two governs).
=== 05/backlog :127 ===
**Description.** The cross-track consistency pass (F21) found that `pii-governance.md` specified a bulk retention sweep and a subject-deletion request sharing one code path, each writing a `deletion_log` row, and `multi-db-strategy.md` §5 defined the table — but **no method on the interface could reach any of it.** `Profiles().Delete` wrote no log row, and §3 forbids 03 from opening a transaction to combine the two, so a policy this track ruled on could not be implemented against the contract this track wrote, and no track owned the gap. Fixed by adding two composite-level methods in `multi-db-strategy.md` §3c: `DeleteExpired(ctx, class, olderThan, maxRows) (SweepResult, error)` and `DeleteProfile(ctx, id, externalRef)`, both writing `deletion_log` in the same transaction as the delete (signatures as finalized after 03's review — batched, and with `reason` asserted by the method rather t
=== observability :37 orphan ===
class` label matching 05's actual retention classes — `source="direct"`, `source="idp_cache"`, and `orphan` for an orphaned `idp_cache` row (`.
=== secret_state restatements ===
03-engineering-delivery/backlog.md:86:- [ ] Asserts the `requires_secret`/`secret_state` relationship (§6.11) and `is_active` (§6.10) — the two conditions 05 named as unable to have a DDL enforcement site.
05-data-ops/backlog.md:52:**Description.** Turn the prose strategy in `multi-db-strategy.md` into a contract 03 can implement without re-deriving this track's reasoning: interface signatures, a per-method summary table, domain types, the full sentinel set with
05-data-ops/backlog.md:56:- [x] Every caller-visible guarantee names its enforcement site; the two weakest sites (`is_active`, and `requires_secret` ↔ `secret_state`, both unable to be DDL constraints because `CHECK` cannot reference another table) are named a
=== 03/CLAUDE:36 ===
Planning complete (2026-09-12): 4 hires spawned and live, `PLAN.md` approved, Service boundaries published in root `PLANNING.md`, four implementation-call decisions ruled (`decisions/go-layout-debate.md`, `error-semantics.md`, `context-propagation.md`, `test-double-strategy.md`), 05's DAO contract accepted (including a second-pass review and 05's resulting `CreateProfileWithCredential` fix), 02's `handoff-03-auth.md` v2 accepted, `backlog.md` written (10 stories, Epic `LT-3`). No `.go`/`.sql`/`go.mod` exists yet — still waiting on Warren's explicit implementation go-ahead.
=== pii-gov :35 names 04 doc? ===
| `user_credential` | — | **No independent window** | Wholly governed by its profile's window via the cascade FK. A credential cannot outlive its profile; a credential row with no profile cannot exist. A second clock could only ever disagree with the first, with no resolution rule | n/a | n/a | **Ruled** |
**The audit-stream row is enforced by 04, not by this schema.** Its enforcement site is 04's log-sink retention policy (`../04-infra-devops/observability.md`), which is what the consistency pass ruled and what `LT-20` cites. Nothing in the DAO reaches it: the sweep methods in `multi-db-strategy.md` §3c operate on `RetentionClass` values, all three of which are `user_profile` classes.
**The `deletion_log` table itself has no sweeper, and that is a named gap rather than an omission.** It is not in the table above because it would be a row nobody can act on. Two facts make it awkward in a way worth stating rather than tidying away:
1. **Nothing forces a window on it.** The table is PII-free by construction (`multi-db-strategy.md` §6.11), so it is *not* subject to the storage-limitation clock every other row here answers to. "Keep it forever" is therefore the path of least resistance rather than a decision anyone made — which is precisely why it needs one.
2. **No method sweeps it.** `DeleteExpired` takes a `RetentionClass`, and there is no class for it. If a window is ruled, implementing it is a **contract change** — either a fourth class or a separate method — not a configuration value someone can set. Flagging that now so the cost is visible at the time of the ruling rather than discovered after it.
Owner of the ruling: unknown, same as the windows above. Owner of the implementation once ruled: 05 (contract) then 03 (code), by the same route as F21.{
"command": "cat >> /Users/warrenthompson/Source/LoginID/decisions/cross-track-consistency.md <<'EOF'\n\n---\n\n# Third pass — 2026-09-12, after the re-run fixes\n\nScope per team-lead: diff-read of seams 1/4, 3, 5, 9 against the current files, plus the new seam 13 (restatements) from `PLAN.md:143`, applying its scope rule — flag restatements that assert current truth; do not flag decision records quoting prior state; superseded copies removed under a banner are correct. Line numbers are from the files as they stand now.\n\n## Seams 1 and 4 — AGREE\n\n`connector-security.md` §1 (`:19-23`) and §2 (`:27-28`) now describe one model throughout: the caller (`api-service`) calls our `/auth`, holds the vendor token for the span between calls, presents it as a header on our `/identity`; the connector uses a presented token once, never caches, never re-fetches or retries on its own initiative; re-auth is the caller's decision. Attack-tree row 3 (`:81`) re-justifies the accepted replay risk on the two-party no-persist discipline rather than the abandoned one-function window. `api-connector-design-rationale.md:40,72` now say `Authenticate`/`FetchIdentity` \"map one-to-one onto the connector's own exported `/auth` and `/identity` endpoints\" and that the onboarding flow, via `api-service`, \"does hold the token briefly between the two calls — that is the design\" — the re-run's regression is gone. `ux-notes.md:27`, `handoff-04-secrets.md` rows 6 and 7 (`:21-22`) and \"What 04 does not decide\" (`:55`) restate the same model. `context-propagation.md:37` and `secrets-delivery.md:16-17` are clean. No regression.\n\n## Seam 3 — AGREE\n\n`handoff-03-auth.md:20` now says the signing key \"never touches the verifying `api-service` Deployment … runs as its **own Deployment** (`APP_MODE=issuer`, its own ServiceAccount) … the separation has to be a second Deployment, not RBAC alone\" — matching `handoff-04-secrets.md:8,17`. `PLANNING.md:46` lists `APP_MODE` = `verifier` (default) | `issuer` alongside `CONNECTOR_JWT_AUDIENCE` and `CONNECTOR_CLIENT_ID`/`_SECRET`. `containerization-design.md:68-115` adds the `api-service-issuer` Deployment, its `Service` (`:109-111`) and a reachability paragraph (`:115`: the verifier fetches JWKS from `https://api-service-issuer.loginid-poc.svc/jwks`). `local-dev-loop.md` remains file-based. Version labels in 04's five references still read v2 and the hand-off is v2. No regression.\n\n## Seam 5 — AGREE\n\n`multi-db-strategy.md:7-11` replaces the stale preamble listing with a banner that names each retracted claim and points to §1–§5 as authoritative — a superseded copy removed under a banner, which seam 13 defines as correct. `:225` now attributes identity resolution to `api-service` \"which is the party that both decides identity and holds a DAO handle\", citing the go-layout diagram; F22 closed. `pii-governance.md:76-77` restates §3c exactly (`DeleteExpired(ctx, class, olderThan, maxRows) (SweepResult, error)`; `DeleteProfile(ctx, id, externalRef)` with `reason` \"not a parameter\"). `03/PLAN.md:23` and `go-layout-debate.md:45-48` no longer describe a token cache. Sentinel set unchanged and still byte-identical in `error-semantics.md:33`. No regression.\n\n## Seam 9 — AGREE\n\n`pii-governance.md:35` adds the \"Audit-log stream\" row (clock: entry timestamp; window proposed, basis unknown; disposal \"Expiry at the sink\"); `:37-45` names 04's log-sink policy as its enforcement site and states, with two reasons, why the `deletion_log` table is deliberately not a retention row (PII-free, so no storage-limitation clock forces one; no `RetentionClass` sweeps it — a future window is a contract change, owner 05 then 03). `observability.md:41-45` cites that row by name, states the disposal identically, and separates the stream from the table in its own paragraph. `04/backlog.md:110` (LT-20) matches. Never-log items 10–11 and `error-semantics.md:42` unchanged and consistent. No regression.\n\n## Seam 13 — Restatements — CONTRADICTION (five stale restatements, none load-bearing)\n\nMethod: for each authoritative definition, grep every other document and compare. Checked: `dao.New` and the `Repository` composite; `Search`/`ProfileQuery` and the `Limit`/`Offset` bounds; `DeleteExpired`/`DeleteProfile`; the seven sentinels; `user_credential` shape (`secret_state`); every config variable in `PLANNING.md:46`; the issuer/audience literals; hand-off version labels; the four retention rows and class names. Matches: `dao.New` (6 restatements, all identical); `Search` signature (`search-query-shape.md:23` identical); `DeleteExpired`/`DeleteProfile` (`pii-governance.md:76-77`, `05/backlog.md:127`, `03/backlog.md:191-198` all match §3c); the sentinel set (`error-semantics.md:33`; `:20`'s old names sit under a \"Superseded\" heading — history, not flagged); `secret_state` (two backlogs, consistent); the literals `https://auth.loginid-takehome.internal`, `loginid-api-service`, `idp-connector-service`, `connector:identity-lookup` (identical in every document); retention numbers 24 months / 30 days / 7 days (only `pii-governance.md` and the `PLANNING.md` 05 row, consistent); `handoff-04-secrets.md` v2 (all five 04 references correct); `03/PLAN.md:97-102` now carries a Phase 1 snapshot banner deferring to `PLANNING.md` — correct under the rule. Not flagged: `PLAN.md:133-134` name \"v2\"/\"v1\" inside the checklist as written before the pass — a record of what was to be checked, i.e. prior state; `search-authz-scoping.md:20` \"cursor-based (not offset)\" is the proposal as debated — history.\n\nStale restatements asserting current truth:\n\n1. **`03/PLAN.md:23`** — S4 consumes \"token lifecycle: no-cache-by-default, fetch per `/identity` call, discard on return — TTL/refresh apply only to how quickly a re-fetch happens\" — the connector-fetches model that `connector-security.md:19-23` superseded under F3 (the connector no longer fetches; the caller presents). Written during the F17 fix, so it asserts current truth. Owner: Renata.\n2. **`go-layout-debate.md:45-48`** — the diagram's connector box now reads \"no-cache-by-default, fetch-per-call\" (same superseded model), and its `internal/config` list (`:63-66`) omits `CONNECTOR_JWT_AUDIENCE`, `CONNECTOR_CLIENT_ID`/`_SECRET` and `APP_MODE`. The `:107` note covers only `internal/app`. Either extend that note to say the diagram predates F3/F13/F15, or redraw. Owner: Renata.\n3. **`PLANNING.md:47`** — the composite is restated as `Profiles()`, `Credentials()`, `Methods()`, `CreateProfileWithCredential`, `Close()`; the contract's composite (`multi-db-strategy.md:91-106`) also carries `DeleteExpired` and `DeleteProfile` (§3c). `03/backlog.md:28` restates the same incomplete set. Owner: Renata.\n4. **`PLANNING.md:46`** cites \"`handoff-03-auth.md` v2\" and **`03/CLAUDE.md:36`** says \"`handoff-03-auth.md` v2 accepted\"; the hand-off is v3 and `03/backlog.md:101,111,131` already say \"v3, accepted\". Owner: Renata.\n5. **`observability.md:37`** labels the orphan class `orphan` \"matching 05's actual retention classes\"; the contract's `RetentionClass` value is `\"idp_cache_orphan\"` (`multi-db-strategy.md:256`). A metric label may legitimately differ from a Go constant, but the sentence claims a match that is not literal. Owner: Theo — one word, or drop \"matching\".\n\nAll five are one-line edits by the restating document's author; none changes what any track builds.\n\n## Verdict\n\n**The submission is now internally consistent enough to declare planning done.** All four re-run regressions and every listed leftover are fixed, and the fixes introduced no new regression: the connector's token model is stated identically in 01, 02 and 04; the issuer topology is one design across 02, 03's config surface and 04's manifests; the DAO contract's preamble no longer competes with the contract; and the audit-stream/`deletion_log` distinction is drawn the same way by 05 and 04. Seam 13 found five stale restatements, all cosmetic, four of them in 03's documents — they can be cleaned during Phase 4 transcription without another consistency pass. Carry-overs deliberately left open and correctly labelled as such: goose `version_id` uniqueness (unverified), the `deletion_log` table's retention window (no mechanism, flagged as a contract change), and F51 (phone E.164 normalization site, seam 12 — not reopened here).\n\n---\nModel: fable (consistency-pass third pass, same fresh-context session). Turns consumed: 1 diff-read pass.\nEOF\necho appended",
"description": "Append the Third pass section to the consistency report"
}appended
{
"to": "team-lead",
"summary": "Third pass verdict: planning done",
"message": "Third pass: planning is internally consistent enough to declare done. Section appended to decisions/cross-track-consistency.md.\n\nSeams 1/4, 3, 5, 9 all AGREE — every re-run regression and leftover is fixed, no new regression: the caller-holds-token model is stated identically in connector-security.md §1/§2/row 3, 01's rationale :40/:72, ux-notes:27 and handoff-04 rows 6/7; the issuer is one design across handoff-03-auth:20, PLANNING's APP_MODE, and 04's issuer Deployment + Service + reachability paragraph; the DAO preamble is a banner; :225 names api-service; the audit-stream row and the deliberately-excluded deletion_log table are drawn the same way by 05 and 04.\n\nSeam 13 found five stale restatements, all cosmetic, none load-bearing: 03/PLAN.md:23 and the go-layout diagram (:45-48, :63-66) still describe the connector-fetches model and an old config list; PLANNING.md:47 and 03/backlog:28 omit DeleteExpired/DeleteProfile from the composite; PLANNING.md:46 and 03/CLAUDE.md:36 cite handoff-03-auth v2 (now v3); observability:37 says `orphan` vs the contract's `idp_cache_orphan`. Owners Renata (four), Theo (one). Cleanable during Phase 4 transcription; no further pass needed.",
"type": "message",
"recipient": "team-lead",
"content": "Third pass: planning is internally consistent eno…"
}{"success":true,"message":"Message sent to team-lead's inbox","msg_id":"2f1c1a61-e097-44a7-9c53-8055dc48917e","routing":{"sender":"consistency-pass","senderColor":"green","target":"@team-lead","summary":"Third pass verdict: planning done","content":"Third pass: planning is internally consistent eno…"}}