Oren Castellan

Hire (track 03) · session 10 of 26 · model claude-sonnet-5 · Sep 13, 2026 4:47 AM PDT – Sep 13, 2026 4:48 AM PDT
Sep 13, 4:47 AM PDTLead
You previously reviewed GitHub PR #30 (wtvamp/loginid-takehome, branch feature/LT-40) and found two minor issues: (1) singleScope() counted claim occurrences instead of distinct scope values, so "profile:search profile:search" was wrongly rejected as "more than one scope"; (2) bearerToken()'s "Bearer " prefix match was case-sensitive, against RFC 7235's case-insensitive auth-scheme convention. The branch has since been updated (new commit 03181f6) — both of your findings were fixed (dedup by distinct Scope value; strings.EqualFold for the prefix match), plus a substantial set of other changes responding to Nolan Reyes's and a 02 cold-reviewer's (Tomasz Wrede) findings: JWKS fetch now threads context through (was previously ignoring it and using a client with no timeout), the cache lock is no longer held during network I/O, JWKS staleness is now bounded (fails closed past 3x the TTL) with a distinct audit event, an unknown kid triggers one rate-limited out-of-band refetch, the GrantLimiter's maps now get swept to prevent unbounded growth, and cmd/api-service/main.go no longer crash-loops when the database doesn't exist yet (fails closed with 503 on the protected routes instead, while /healthz stays green). Re-review the CURRENT full state — run `gh pr diff 30 --repo wtvamp/loginid-takehome` for the complete diff. Verify: 1. Are your two original findings actually fixed correctly? 2. Trace the new JWKS staleness logic (refresh(), reserveUnknownKidRetry, the maxStaleness check) for correctness — any off-by-one, race, or logic error in the fail-closed threshold? 3. Any new issues introduced by this round's changes? Report back in plain prose (not a tool call): concrete findings if any, or confirmation everything checks out, if that's your honest assessment.
Sep 13, 4:47 AM PDTOren Castellan
Bash pwd
input
{
  "command": "pwd"
}
result
/Users/warrenthompson/Source/LoginID/03-engineering-delivery
Read /Users/warrenthompson/Source/LoginID/CLAUDE.md
input
{
  "file_path": "/Users/warrenthompson/Source/LoginID/CLAUDE.md"
}
result
1	# LoginID Take-Home POC — Project Root
2	
3	## Role
4	
5	You are the project manager agent for this repository. This file is the shared context every track refers back to. Each of the five track directories below has its own CLAUDE.md that can be worked independently by a scoped subagent, but that subagent should read this root file first for the assignment, the overall narrative, and how its track fits with the other four.
6	
7	When you spin up agents or subagents to do work in a track, point them at that track's CLAUDE.md as their brief, and tell them this root file exists for cross-track context. Don't let a track's agent silently duplicate scope another track owns — check the "Owns / does not own" boundaries below first.
8	
9	## What this repository is
10	
11	This is a take-home assignment for a LoginID interview, submitted as a full directory. The goal is not just to answer three coding questions — it's to demonstrate capability as an AI architect: how you scope a problem, use AI tooling deliberately, reason about security and data, and produce something that reads as an intentional, well-run engineering effort rather than a quick script.
12	
13	Code does not need to work or be fully complete. LoginID has said explicitly they care more about the design and the reasoning behind it, and about which AI tools/frameworks were used and how. Every track should document its reasoning, not just its output.
14	
15	## The assignment (verbatim from LoginID)
16	
17	Provide code solutions for the following questions in Go (preferably) or other languages — describe any tool, framework, or AI used.
18	
19	1. Code of a database DAO that can store and retrieve `user_profile` (name, address, phone) and `user_credential` (username, method, password), with support for multiple databases (PostgreSQL or CockroachDB, SQLite, etc).
20	2. Design a RESTful API web service to search and retrieve the above user profile data, plus security API authentication.
21	3. Design a service connector to third-party identity providers (referred to generically as ABC or XYZ) to retrieve personal data from vendors, with the following endpoints:
22	   - `/auth` — retrieves an `access_token` based on a POST JSON body `{"username": "<string>", "password": "<string>"}`
23	   - `/identity` — retrieves user PII (name, phone, address: `street_address`, `locality`, `region`, `postal_code`, `country`) based on a POST JSON body `{"phone": "<string>", "name": "<string>"}`
24	
25	Go is the preferred language. Other languages are acceptable if justified.
26	
27	## Track structure
28	
29	Five directories, each independently workable, each owning a distinct slice of the problem:
30	
31	| Directory | Track | Owns |
32	|---|---|---|
33	| `01-product-industry-research-design/` | Product & Industry Research (incl. Design) | Market/industry framing (IAM, passwordless auth, IDP aggregation space), personas and use cases, API and data contract design rationale, UX of the surfaces that would sit on top of this |
34	| `02-ai-security-architecture/` | AI Architecture & Security Architecture | The AI tooling/workflow used to produce this submission, threat modeling, auth/authz design for the API, credential and PII handling, secrets management, third-party connector security |
35	| `03-engineering-delivery/` | Engineering & Delivery | The actual Go code for all three questions, project structure, test strategy, delivery documentation |
36	| `04-infra-devops/` | Infra & DevOps | How this would be built, containerized, deployed, and operated — CI/CD, environments, multi-database configuration, observability |
37	| `05-data-ops/` | Data Ops | Data modeling for `user_profile` / `user_credential`, multi-database abstraction strategy, migrations, PII data governance and retention |
38	
39	Directories are numbered for reading order in the final submission, not for sequencing of work — tracks can be worked in any order or in parallel.
40	
41	## Working principles for every track
42	
43	- State your reasoning, not just your conclusion. LoginID is grading the "why" as much as the "what."
44	- Name the AI tool/workflow you used for that track's output (this project is being built with Claude Code; say so, and say how — e.g., subagents scoped per track, a given skill, a given model).
45	- Keep each track's CLAUDE.md self-contained enough that a fresh agent with no other context can pick it up and do correct work from it alone, plus this root file.
46	- Cross-reference other tracks by relative path (e.g., `../05-data-ops/CLAUDE.md`) rather than restating their content.
47	- Implementation is gated per story, not project-wide: Warren gave the implementation go on 2026-09-12 (evening) as an agile loop. Code (`.go`, `.sql`, `go.mod`, Dockerfiles, CI config) may be written only for a story the Product Owner has pulled, refined with the research team, and handed to engineering — recorded in `<track>/refinement/<LT-key>.md`. Work happens on a `feature/<LT-key>` branch, never directly on `main`. A story is done only when it is deployed to the public URL and the Product Owner has reviewed it there.
48	- **Attribution rule (Warren, 2026-09-12): every commit, PR, and Jira ticket says who did the work.** *Commits:* the git author is the persona doing the work — set once per worktree with `git config --worktree user.name "<Persona Name> (<track> <role>, <model>)"` and `git config --worktree user.email "<slug>@agents.loginid-takehome.invalid"` (`extensions.worktreeConfig` is enabled repo-wide) — and every commit message ends with trailers: `Story: LT-nn`, `Work-By: <persona> (<seat>); <persona> (<seat>)…` naming each hire who contributed and what they did (implementation, tests, review, objection seat), then `Co-Authored-By: Claude <model> <noreply@anthropic.com>`. *PRs:* title `LT-nn: <summary>`; body has a **Who did the work** section (lead, hires and seats, reviewers incl. 02's independent reviewer where applicable, models), plus links to the refinement doc and the Jira issue. *Jira:* every transition and every PR link is a comment beginning `Work-By: …` with the same names; the story's final comment before Done names who deployed and who reviewed. The PM applies the same rule to its own commits. Persona names, not "Claude" or "the team".
49	- **Shared working tree rule.** Every lead session shares this one checkout, so `git checkout`/`git switch` here changes everyone's tree — **never run them in this directory.** Feature branches live in worktrees outside the repo: `git worktree add /Users/warrenthompson/Source/LoginID-worktrees/<LT-key> -b feature/<LT-key> main`; work, commit, and push from that directory; the root tree stays on `main`. Remote: `origin` = `«email»:wtvamp/loginid-takehome.git` (public). Go code lives at the repo root (`cmd/`, `internal/`, `go.mod`); track directories hold documents.
50	
51	## Org, hiring, and orchestration
52	
53	The project is run as an org: this PM agent (Dana Whitfield) orchestrates five standing track-lead agents, and each lead hires a small sub-team of specialist agents with deliberately varied personalities. **Each track lead runs as its own top-level Claude Code session**, launched from its track directory in its own tmux window — so it loads its own track `CLAUDE.md` and persona natively, owns and spawns its hires as named teammates in that window, and talks to the PM session through cross-session messaging. Hires belong to their lead's session, not the PM's. The governing files, all at the root:
54	
55	- `PLAN.md` — the approved org plan for the planning phase (phases, gates, verification). `~/.claude/plans/` is scratch; this is the record.
56	- `PLANNING.md` — the thin cross-track board: one row per track, one writer per row, plus the service-boundaries section 03 owns and 04 reads.
57	- `CASTING.md` — archetype palette, org-wide variance rules, hire sizing, the persona-writing standard, the orchestration-pattern catalog (adversarial pair, red/blue, three hats, rotating devil's advocate, structured written debate, newcomer's question), the deep-research rule, the hire-creation procedure, and the registry of every hire.
58	- `LOG.md` — dated, append-only journal of what happened and why (team-lead writes it; it is part of the submission).
59	- `.claude/agents/` — one agent definition per hire (model pinned; a once-only per-agent `PreToolUse` hook displays its own persona on its first tool call). `.claude/AGENT_TEMPLATE.md` is the starting point (kept outside the agents directory so it is not registered as an agent type itself).
60	- `02-ai-security-architecture/planning-approach.md` — the agreed Claude-architecture approach: model/effort by role, coordination rules, hand-off standard, dependency order.
61	
62	Rules that hold across every track:
63	- Every agent — lead or hire — has a profile-gen persona (name, temperament, professional history, technical background, stated blind spot). Hires are created text-first with `--output file`; portraits arrive later from `scripts/portrait-queue.sh`. Hires are **never** added as `profile-gen` markers in any `CLAUDE.md` — only the six markers that exist today.
64	- Hires do not spawn subagents. Only track leads (and team-lead) authorize or run deep research (patents, whitepapers, standards, primary sources); hires request it from their lead.
65	- Tracks never modify root-level state (this file, the root persona, root config); flag it to team-lead instead.
66	- **Decisions already made are not re-asked.** Warren's rulings reach tracks through the PM and are recorded in `LOG.md` and this file. A lead must not raise an `AskUserQuestion` to Warren for a decision that is already on the record (e.g., the deployment target, the public repo, the code go) — it asks the PM by message, which answers from the record or escalates. A dialog left open in a lead's session blocks that lead and everything queued behind it (this cost DevOps an hour on 2026-09-12). Genuinely new decisions that only Warren can make still go to Warren — through the PM.
67	- Hand-offs are files by relative path, written so the receiver can act without re-deriving the sender's reasoning; chat messages signal readiness, they do not carry content.
68	- Edit `CLAUDE.md` files at turn boundaries only (prompt-cache stability). Milestone `git` commits at the end of each phase; no mid-task commits.
69	
70	## Status
71	
72	Planning Phases 0–3 are complete (2026-09-12): five track plans approved, 18 hires live in five lead sessions, every track's Phase 3 debates and hand-offs delivered, and the cross-track consistency pass reported (`decisions/cross-track-consistency.md`: 52 findings, six bounded fixes required before planning is declared done — in progress, owned per finding). Phase 4 is under way: Jira Epics `LT-1`…`LT-5` exist, tracks 01 and 05 are transcribed, 02/03/04 follow the pass fixes. All implementation code was deliberately wiped earlier in the day and none has been written since; **no code until Warren's explicit go-ahead**, which he gives after the PM reports the backlog implementation-ready.
73	
74	<!-- profile-gen:start slug=dana-whitfield -->
75	@profiles/dana-whitfield/dana-whitfield.md
76	<!-- profile-gen:end slug=dana-whitfield -->
77	
Read /Users/warrenthompson/Source/LoginID/03-engineering-delivery/CLAUDE.md
input
{
  "file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/CLAUDE.md"
}
result
1	# Track: Engineering & Delivery
2	
3	Refer back to `../CLAUDE.md` for the full assignment text and how this track fits with the other four.
4	
5	## Mission
6	
7	Write the actual code that answers all three assignment questions, in Go, implementing the designs the other tracks produce rather than re-deciding them here. Code does not need to be complete or working end-to-end — LoginID cares about the design and reasoning — but it should be structured and readable enough to demonstrate real engineering judgment.
8	
9	## Owns
10	
11	- Question 1: the DAO layer for `user_profile` (name, address, phone) and `user_credential` (username, method, password), written to support multiple backing databases (PostgreSQL/CockroachDB, SQLite, etc.) behind a common interface, per the strategy in `../05-data-ops/CLAUDE.md`.
12	- Question 2: the RESTful API service to search/retrieve `user_profile` data, wired to the authentication mechanism designed in `../02-ai-security-architecture/CLAUDE.md`.
13	- Question 3: the third-party IDP connector service implementing `/auth` and `/identity` against the contracts specified in the assignment, following the connector security design in `../02-ai-security-architecture/CLAUDE.md`.
14	- Project structure, module layout, and inline documentation of design decisions and trade-offs made during implementation.
15	- Test strategy (unit/integration scope, what's stubbed vs. real) — tests don't need full coverage, but the approach should be stated.
16	
17	## Does not own
18	
19	- Deciding the API auth mechanism or threat model — that's `../02-ai-security-architecture/CLAUDE.md`; implement what it specifies.
20	- Deciding the multi-database abstraction strategy or schema — that's `../05-data-ops/CLAUDE.md`; implement what it specifies.
21	- Deployment, containerization, CI/CD — that's `../04-infra-devops/CLAUDE.md`.
22	- Product/industry rationale for why the API is shaped this way — that's `../01-product-industry-research-design/CLAUDE.md`; reference it, don't re-derive it.
23	
24	## Deliverables
25	
26	- Go source implementing questions 1, 2, and 3.
27	- A short README in this directory explaining what's implemented, what's stubbed/mocked, and why, plus which AI tool/workflow was used to produce the code and how.
28	- Any test files demonstrating the stated test strategy.
29	
30	## AI tooling note
31	
32	Record which AI tool/workflow generated this track's code (this project is being built with Claude Code) and how much was AI-authored vs. human-reviewed/edited.
33	
34	## Status
35	
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` v3 accepted, `backlog.md` written (11 stories, Epic `LT-3`). No `.go`/`.sql`/`go.mod` exists yet — still waiting on Warren's explicit implementation go-ahead.
37	
38	<!-- profile-gen:start slug=renata-cole -->
39	@profiles/renata-cole/renata-cole.md
40	<!-- profile-gen:end slug=renata-cole -->
41	
Read /Users/warrenthompson/Source/LoginID/03-engineering-delivery/PLAN.md
input
{
  "file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/PLAN.md"
}
result
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	*This table is the Phase 1 planning snapshot (2026-09-12). `backlog.md` (11 stories, Jira Epic `LT-3`) is the current, authoritative, PR-sized expansion — its story descriptions and acceptance criteria supersede this table's "What"/"Acceptance standard" columns wherever they differ. Consult `backlog.md`, not this table, for current story shape, hand-off versions, or acceptance criteria.*
19	
20	| Story | What | Hand-off consumed | Hand-off produced | Acceptance standard |
21	|---|---|---|---|---|
22	| 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 |
23	| 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 |
24	| 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 |
25	| 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 |
26	| 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-driver ruling is conditional on (`multi-db-strategy.md` §7) — one suite, written once against the `Repository` interface, run against every backend** (consistency-pass finding F25: this was a requirement with no named deliverable until now; `backlog.md`'s S5 is the current, authoritative expansion of this story) | S1–S4 outputs | none | Stated strategy: unit-heavy, integration deliberately stubbed (assignment doesn't require full end-to-end); documented, not silently absent |
27	| 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 |
28	
29	## 2. Hire roster — 4 hires (suggested cast from `CASTING.md`, adopted as-is)
30	
31	All four: **model sonnet** (planning/design/implementation tier per `planning-approach.md`;
32	opus is reserved for the connector token-lifecycle *implementation* seat in a later phase, not
33	planning — none of these four need it now). No hire spawns subagents; no CLAUDE.md marker;
34	profile-gen text-first, after "hiring go".
35	
36	**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.
37	
38	*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)
39	
40	**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.
41	
42	*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)
43	
44	**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.
45	
46	*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)
47	
48	**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.
49	
50	*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)
51	
52	Roster spans both required axes (serious/playful via Marisol; cautious/bold via Ines and
53	Marisol), includes Renata's required opposite (Marisol), and duplicates no archetype from
54	another track's *function* (Detail Hawk here is a code reviewer; 05's Detail Hawk is a DDL
55	reviewer, a different function — allowed per variance rule 2).
56	
57	## 3. Debate plan
58	
59	- **Three hats — Go layout (one binary vs. two).** Marisol (optimist: ship one binary, less
60	  ceremony) / Nolan (pessimist: two binaries, because a shared-everything binary is how a
61	  card-issuing platform he worked on ended up with the connector's outbound vendor calls
62	  taking down the client-facing API during an incident) / **Renata takes the pragmatist hat
63	  herself** rather than hiring a fifth (catalog explicitly allows this) — she's the
64	  Minimalist-leaning lead per `CASTING.md` §7. Ines drafts the resulting diagram either way.
65	  Decision recorded in `decisions/go-layout-debate.md`.
66	- **Rotating devil's advocate — implementation calls.** Error semantics (wrap vs. sentinel
67	  errors), context propagation into the DAO layer, test-double strategy (interface fakes vs.
68	  generated mocks). Seat rotates Oren → Nolan → Marisol → Ines per decision so no one hire
69	  becomes "the objector." Cheapest pattern; run on every non-trivial implementation call in
70	  S2–S4.
71	
72	  *(Consistency-pass finding F47: this section named "Callum" as the pessimist/rotation-2 hire
73	  through Phase 1 while the actual roster in §2 above was already "Nolan Reyes" — Callum
74	  Reyes was renamed to clear a first-name collision with 04's Callum Ferreira before hiring;
75	  this section simply hadn't been updated to match. Fixed above; no roster change.)*
76	- **Newcomer's-question pass on hand-offs — run by Renata herself, not a hired Newcomer.**
77	  This track wasn't allocated a Newcomer seat (04 has one; hiring a second here would exceed
78	  the intent of a 4-hire cap for marginal gain). Renata reads 05's `multi-db-strategy.md` and
79	  02's receiver-shaped hand-offs cold, the moment they land, and lists anything she can't act
80	  on without re-deriving the sender's reasoning — same test the pattern exists to run, just
81	  performed by the actual receiver instead of a proxy.
82	- **Explicit waste:** the `/auth` and `/identity` endpoint paths and JSON bodies are fixed by
83	  the assignment text — never debated. Red team/blue team is 02's pattern for the token
84	  lifecycle's security properties, not this track's to re-run; 03 only debates *how* it
85	  implements what 02 specifies (error handling, retries), never *whether* the scheme is right.
86	
87	## 4. Dependencies (and the Service boundaries output for 04)
88	
89	**Split start**, consistent with `planning-approach.md` §2: S1 (scaffolding) starts as soon as
90	hiring completes — no upstream blocker. S2 (DAO) is blocked on 05's Go-shaped contract landing
91	*stable* (not polished — unlikely to be rewritten). S3's middleware is blocked on 02's
92	receiver-shaped auth hand-off; S3's route wiring and non-auth handler logic can start earlier.
93	S4 is blocked on 02's `connector-security.md` token-lifecycle spec, which already exists in
94	prose form — implementable now, refined if 02's deferred receiver-shaped version changes
95	anything.
96	
97	**Service boundaries (written into root `PLANNING.md` alongside this plan):**
98	
99	*This subsection is the Phase 1 snapshot (2026-09-12, before hiring). Root `PLANNING.md`'s
100	Service boundaries section is the current, authoritative version — it has since gained
101	`internal/app` (shared bootstrap, `decisions/go-layout-debate.md`), `DB_DSN_FILE` (02's
102	`handoff-04-secrets.md` S7), literal `AUTH_JWT_ISSUER`/`AUTH_JWT_AUDIENCE` values, and the
103	connector's own audience/client-credential variables (consistency-pass F15). Consult
104	`PLANNING.md`, not this snapshot, for what 04 or anyone else should build against.*
105	
106	- **Two binaries**, not one: `cmd/api-service` (Q1 DAO + Q2 REST API, client-facing, bearer-
107	  token auth) and `cmd/idp-connector` (Q3, outbound-only to vendors). Reasoning: different
108	  threat models and auth surfaces per 02's track, independent scaling/deploy needs, and it
109	  mirrors the assignment's own Q2/Q3 split rather than inventing a new boundary.
110	- **Package layout:**
111	  ```
112	  cmd/api-service/main.go
113	  cmd/idp-connector/main.go
114	  internal/dao/        # Repository interface + postgres/cockroachdb/sqlite implementations
115	  internal/model/      # shared structs: UserProfile, UserCredential
116	  internal/api/         # REST handlers, router, auth middleware
117	  internal/connector/   # /auth, /identity client + handlers
118	  internal/config/      # env-var config loading, shared by both binaries
119	  ```
120	- **Config surface:** environment variables only (12-factor, container-friendly for 04) —
121	  `DB_DRIVER` (postgres|cockroachdb|sqlite), `DB_DSN`, `HTTP_ADDR`, `AUTH_JWT_ISSUER` /
122	  `AUTH_JWT_AUDIENCE` (names pending 02's final scheme), `IDP_ABC_BASE_URL`,
123	  `IDP_ABC_CLIENT_ID` / `IDP_ABC_CLIENT_SECRET`. No flags, no config files.
124	- **DAO driver-selection mechanism:** a factory `dao.New(driver, dsn string) (dao.Repository, error)`
125	  chosen at startup from `DB_DRIVER`; every backend satisfies one `Repository` interface
126	  (exact method signatures finalized once 05's contract lands — the factory *shape* is stable
127	  now, its return type's method set is not).
128	- **What 04 may treat as stable:** the two-binary split, the `cmd/` entrypoint names, the
129	  env-var config approach and the variable names above, and the `dao.New(driver, dsn)` factory
130	  signature. **Not yet stable:** internal DAO implementation details, ORM/query-builder choice,
131	  and handler-package internals — those can still shift as 05's and 02's hand-offs land.
132	
133	## Amendment: QA function (2026-09-12, Warren's agile-loop go-ahead)
134	
135	**Oren Castellan leads QA** (unit, integration, and the end-to-end/UX acceptance tests written from the PO's review script); **Nolan Reyes supports**. 02's independent second review still applies on top of this for every `security-graded` story — QA and 02's review are not the same gate.
136	
137	## Verification
138	
139	- `PLAN.md` for this track matches this file verbatim once copied.
140	- `PLANNING.md` row 03 updated to "plan drafted — awaiting hiring go"; Service boundaries
141	  section replaces its "TBD" with the content in §4 above.
142	- No `.go`, `.sql`, `go.mod`, or agent-definition files exist yet — only prose changed.
143	- Roster (4 hires) checked against `CASTING.md` variance rules before messaging team-lead:
144	  no archetype repeated within this team; Renata's required opposite (Enthusiast) present;
145	  two temperament axes spanned; no first name collides with any lead.
146	
Read /Users/warrenthompson/Source/LoginID/03-engineering-delivery/profiles/oren-castellan/oren-castellan.md
input
{
  "file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/profiles/oren-castellan/oren-castellan.md"
}
result
1	---
2	schema_version: 1
3	name: "Oren Castellan"
4	slug: "oren-castellan"
5	image: "profiles/oren-castellan/oren-castellan.png"
6	
7	nsfw: false
8	display:
9	  image: true
10	  name: true
11	  autostart: false
12	generation:
13	  backend: "comfyui"
14	  model: "comfyui-workflow.json"
15	  prompt: "Oren Castellan, a meticulous and understated man in his late 30s with short neat hair, wire-rim glasses, wearing a plain navy button-down shirt, calm focused expression, head-and-shoulders portrait, centered, neutral light gray studio background, professional corporate headshot photography, soft studio lighting, sharp focus, photorealistic"
16	  negative_prompt: blurry, low quality, distorted, extra limbs, watermark, text, nsfw, nude, nudity, explicit, sexual content
17	  seed: null
18	  gif_mode: null
19	  created_at: "2026-09-12T00:00:00Z"
20	---
21	
22	# Oren Castellan
23	
24	![Oren Castellan](profiles/oren-castellan/oren-castellan.png)
25	
26	
27	## Personality
28	
29	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.
30	
31	
Sep 13, 4:47 AM PDTOren Castellan
Bash gh pr diff 30 --repo wtvamp/loginid-takehome > /private/tmp/claude-501/-Users-warrenthompson-Source-
input
{
  "command": "gh pr diff 30 --repo wtvamp/loginid-takehome > /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr30.diff 2>&1; wc -l /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr30.diff"
}
result
    2313 /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr30.diff
Sep 13, 4:47 AM PDTOren Castellan
Read /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706e
input
{
  "file_path": "/private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr30.diff"
}
result
1	diff --git a/cmd/api-service/main.go b/cmd/api-service/main.go
2	index 5c170ee..636be6d 100644
3	--- a/cmd/api-service/main.go
4	+++ b/cmd/api-service/main.go
5	@@ -10,6 +10,10 @@ import (
6	 
7	 	"loginid-takehome/internal/app"
8	 	"loginid-takehome/internal/config"
9	+	"loginid-takehome/internal/dao"
10	+
11	+	_ "loginid-takehome/internal/dao/postgres" // registers "postgres" and "cockroachdb"
12	+	_ "loginid-takehome/internal/dao/sqlite"   // registers "sqlite"
13	 )
14	 
15	 func main() {
16	@@ -24,7 +28,39 @@ func main() {
17	 	}
18	 
19	 	log.Printf("api-service: mode=%s listening on %s", cfg.AppMode, addr)
20	-	if err := http.ListenAndServe(addr, app.NewRouter("api-service", app.Mode(cfg.AppMode))); err != nil {
21	+
22	+	var handler http.Handler
23	+	switch app.Mode(cfg.AppMode) {
24	+	case app.ModeVerifier:
25	+		// The verifying Deployment is the one that talks to the
26	+		// database (LT-39's search/retrieve handlers) — the issuer
27	+		// Deployment (below) never needs a Repository at all, since it
28	+		// only mints tokens.
29	+		//
30	+		// A failure here does NOT Fatal: on a cluster where the database
31	+		// doesn't exist yet (no DB_DRIVER/DB_DSN configured — e.g. before
32	+		// PR #29's Postgres StatefulSet lands), or DB_DRIVER is simply
33	+		// misconfigured, this process must still come up and serve
34	+		// /healthz rather than crash-loop the whole verifying Deployment
35	+		// over a dependency only two of its routes need. repo stays nil;
36	+		// NewVerifierRouter fails those two routes closed with 503
37	+		// instead of panicking on a nil Repository.
38	+		repo, err := dao.New(cfg.DBDriver, cfg.DBDSN)
39	+		if err != nil {
40	+			log.Printf("api-service: opening DAO repository: %v — /profiles/search and /profiles/{id} will return 503 until this is resolved", err)
41	+			repo = nil
42	+		}
43	+		handler = app.NewVerifierRouter(cfg, repo)
44	+	default:
45	+		// ModeIssuer (and the unrecognized-mode fallback, which
46	+		// NewRouter itself already logs loudly and treats as verifier —
47	+		// see app.NewRouter's own doc comment) don't need the full
48	+		// verifier stack; NewRouter's existing /auth/token placeholder
49	+		// (LT-32) and /healthz cover both.
50	+		handler = app.NewRouter("api-service", app.Mode(cfg.AppMode))
51	+	}
52	+
53	+	if err := http.ListenAndServe(addr, handler); err != nil {
54	 		log.Fatalf("api-service: %v", err)
55	 	}
56	 }
57	diff --git a/go.mod b/go.mod
58	index acc3f60..eaf1188 100644
59	--- a/go.mod
60	+++ b/go.mod
61	@@ -3,6 +3,7 @@ module loginid-takehome
62	 go 1.27.1
63	 
64	 require (
65	+	github.com/golang-jwt/jwt/v5 v5.3.1
66	 	github.com/google/uuid v1.6.0
67	 	github.com/jackc/pgx/v5 v5.11.0
68	 	modernc.org/sqlite v1.58.0
69	diff --git a/go.sum b/go.sum
70	index 0863108..cf541fe 100644
71	--- a/go.sum
72	+++ b/go.sum
73	@@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
74	 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
75	 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
76	 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
77	+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
78	+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
79	 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
80	 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
81	 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
82	diff --git a/internal/api/audit.go b/internal/api/audit.go
83	index 7d9b37b..73f791d 100644
84	--- a/internal/api/audit.go
85	+++ b/internal/api/audit.go
86	@@ -25,6 +25,15 @@ const (
87	 	AuditCapExceeded   AuditEventKind = "cumulative_cap_exceeded"
88	 	AuditRequestFailed AuditEventKind = "request_failed" // a DAO/internal error, not a policy decision
89	 	AuditSuccess       AuditEventKind = "success"
90	+
91	+	// AuditJWKSUnavailable is LT-40's distinct signal for the JWKS
92	+	// fail-closed case (Tomasz Wrede, 02 cold review, PM ruling): the
93	+	// cached key set exceeded its max-staleness bound with no successful
94	+	// refresh, and the verifier refused every token rather than trust
95	+	// keys that old — a token-infrastructure degradation, not an
96	+	// ordinary bad-credential attempt, and worth distinguishing from
97	+	// AuditAuthnFailure for exactly that reason.
98	+	AuditJWKSUnavailable AuditEventKind = "jwks_unavailable"
99	 )
100	 
101	 // AuditEvent carries exactly the fields handoff-03-auth.md v3's audit-log
102	diff --git a/internal/api/errors.go b/internal/api/errors.go
103	index 4c00b13..170ed7b 100644
104	--- a/internal/api/errors.go
105	+++ b/internal/api/errors.go
106	@@ -114,3 +114,15 @@ func writeInternalError(w http.ResponseWriter, err error) {
107	 	}
108	 	writeError(w, http.StatusInternalServerError, "internal_error", "internal error")
109	 }
110	+
111	+// WriteServiceUnavailable is exported for internal/app's router wiring:
112	+// when the DAO repository couldn't be constructed at startup (e.g. no
113	+// DB_DRIVER/DB_DSN configured yet), the verifying Deployment must still
114	+// start and serve /healthz — it must not crash-loop the whole process
115	+// over a dependency LT-39's handlers need but nothing else does. Callers
116	+// that have no working Repository at all route the protected endpoints
117	+// to this instead of constructing a Deps with a nil Repo (which would
118	+// panic the first time a handler called d.Repo.Profiles()).
119	+func WriteServiceUnavailable(w http.ResponseWriter) {
120	+	writeError(w, http.StatusServiceUnavailable, "service_unavailable", "service temporarily unavailable")
121	+}
122	diff --git a/internal/api/grantlimiter.go b/internal/api/grantlimiter.go
123	new file mode 100644
124	index 0000000..ba7c35c
125	--- /dev/null
126	+++ b/internal/api/grantlimiter.go
127	@@ -0,0 +1,182 @@
128	+package api
129	+
130	+import (
131	+	"context"
132	+	"sync"
133	+	"time"
134	+)
135	+
136	+// GrantLimiter enforces the token-issuing endpoint's own failed-grant
137	+// rate limiting and progressive backoff — handoff-03-auth.md v3:
138	+// "Token-endpoint brute-force protection, separate from the resource-API
139	+// rate limits ... failed-grant rate limiting and progressive backoff per
140	+// client_id and per source IP on the token-issuing endpoint itself, plus
141	+// alerting on a sustained run of failed grants against one client_id."
142	+//
143	+// This is genuinely separate from RateLimiter (ratelimit.go): that type
144	+// gates the resource API by an already-authenticated sub, at a fixed
145	+// per-minute rate; this one gates the endpoint that issues the
146	+// credential in the first place, keyed independently by client_id AND
147	+// by source IP — a distributed brute force (many IPs, one client_id)
148	+// and a single-source credential-stuffing run (one IP, many client_ids)
149	+// are both attack shapes this is meant to slow down, and either key
150	+// exceeding its own backoff blocks the attempt.
151	+//
152	+// Not wired to a live endpoint by this story: /auth/token's real grant
153	+// logic is LT-51's scope (this story's non-goals). This is the
154	+// ready-to-use component LT-51 constructs and calls from the real
155	+// handler — building it now, tested in isolation, means LT-51 doesn't
156	+// also have to design a backoff/alerting scheme under its own deadline.
157	+type GrantLimiter interface {
158	+	// Allow reports whether a grant attempt for clientID from sourceIP
159	+	// may proceed right now — false while either key is in backoff.
160	+	Allow(ctx context.Context, clientID, sourceIP string) (bool, error)
161	+	// RecordFailure records a failed grant attempt for both clientID and
162	+	// sourceIP, advancing each key's backoff state independently.
163	+	// Crossing the alert threshold for a given client_id (not source IP
164	+	// — the requirement names client_id specifically) fires alert
165	+	// exactly once per crossing, not once per subsequent failure.
166	+	RecordFailure(ctx context.Context, clientID, sourceIP string)
167	+	// RecordSuccess clears backoff state for both clientID and
168	+	// sourceIP — a successful grant resets the count, standard
169	+	// exponential-backoff practice: this deters sustained brute force,
170	+	// it does not permanently degrade a legitimate client that mistyped
171	+	// a secret once.
172	+	RecordSuccess(ctx context.Context, clientID, sourceIP string)
173	+}
174	+
175	+// grantBackoffState is one key's (a client_id's, or a source IP's)
176	+// consecutive-failure bookkeeping.
177	+type grantBackoffState struct {
178	+	consecutiveFailures int
179	+	blockedUntil        time.Time
180	+	alerted             bool
181	+}
182	+
183	+// InProcessGrantLimiter is an in-process implementation. As with
184	+// RateLimiter/TouchCounter's in-process implementations, a shared store
185	+// is the multi-instance replacement behind the same interface — the
186	+// mechanism is this track's call, per handoff-03-auth.md v3.
187	+//
188	+// Both maps are swept periodically (see sweepLocked) rather than left to
189	+// grow forever: this component's own doc comment names "a distributed
190	+// brute force (many IPs, one client_id)" as a defended attack shape, and
191	+// that is exactly the traffic pattern that would otherwise grow
192	+// bySourceIP without bound — every distinct attacking IP earns a
193	+// permanent entry that only a matching RecordSuccess would remove, which
194	+// an attacking IP by definition never triggers (Nolan Reyes, PR #30
195	+// review — the same unbounded-growth shape an earlier PR's TouchCounter
196	+// review already found and fixed once).
197	+type InProcessGrantLimiter struct {
198	+	mu             sync.Mutex
199	+	byClientID     map[string]*grantBackoffState
200	+	bySourceIP     map[string]*grantBackoffState
201	+	baseBackoff    time.Duration
202	+	maxBackoff     time.Duration
203	+	alertThreshold int
204	+	alertFn        func(clientID string, consecutiveFailures int)
205	+	now            func() time.Time
206	+	lastSweep      time.Time
207	+}
208	+
209	+// grantSweepInterval and grantEntryTTL bound how long a key's state
210	+// survives with no further failures against it. A blocked key is never
211	+// swept before its own backoff has elapsed (sweeping mid-block would
212	+// silently un-block it early); grantEntryTTL is generous past any
213	+// realistic maxBackoff so a legitimately still-escalating attacker isn't
214	+// reset early either.
215	+const (
216	+	grantSweepInterval = time.Minute
217	+	grantEntryTTL      = time.Hour
218	+)
219	+
220	+// NewInProcessGrantLimiter constructs a limiter with exponential backoff
221	+// starting at baseBackoff (doubling per consecutive failure, capped at
222	+// maxBackoff) and alertFn called once per client_id crossing
223	+// alertThreshold consecutive failures. alertFn may be nil (no alerting —
224	+// tests use this; production wiring passes a real sink).
225	+func NewInProcessGrantLimiter(baseBackoff, maxBackoff time.Duration, alertThreshold int, alertFn func(clientID string, consecutiveFailures int)) *InProcessGrantLimiter {
226	+	return &InProcessGrantLimiter{
227	+		byClientID:     make(map[string]*grantBackoffState),
228	+		bySourceIP:     make(map[string]*grantBackoffState),
229	+		baseBackoff:    baseBackoff,
230	+		maxBackoff:     maxBackoff,
231	+		alertThreshold: alertThreshold,
232	+		alertFn:        alertFn,
233	+		now:            time.Now,
234	+	}
235	+}
236	+
237	+func (l *InProcessGrantLimiter) Allow(_ context.Context, clientID, sourceIP string) (bool, error) {
238	+	l.mu.Lock()
239	+	defer l.mu.Unlock()
240	+	now := l.now()
241	+	l.sweepLocked(now)
242	+	return !isBlocked(l.byClientID[clientID], now) && !isBlocked(l.bySourceIP[sourceIP], now), nil
243	+}
244	+
245	+// sweepLocked drops any entry whose blockedUntil is at least
246	+// grantEntryTTL in the past — called with l.mu already held, at most
247	+// once per grantSweepInterval, so it adds no per-call cost beyond a
248	+// single time comparison on every other call.
249	+func (l *InProcessGrantLimiter) sweepLocked(now time.Time) {
250	+	if now.Sub(l.lastSweep) < grantSweepInterval {
251	+		return
252	+	}
253	+	l.lastSweep = now
254	+	sweepMap(l.byClientID, now)
255	+	sweepMap(l.bySourceIP, now)
256	+}
257	+
258	+func sweepMap(m map[string]*grantBackoffState, now time.Time) {
259	+	for key, s := range m {
260	+		if now.Sub(s.blockedUntil) >= grantEntryTTL {
261	+			delete(m, key)
262	+		}
263	+	}
264	+}
265	+
266	+func isBlocked(s *grantBackoffState, now time.Time) bool {
267	+	return s != nil && now.Before(s.blockedUntil)
268	+}
269	+
270	+func (l *InProcessGrantLimiter) RecordFailure(_ context.Context, clientID, sourceIP string) {
271	+	l.mu.Lock()
272	+	defer l.mu.Unlock()
273	+	now := l.now()
274	+
275	+	cs := l.advance(l.byClientID, clientID, now)
276	+	l.advance(l.bySourceIP, sourceIP, now)
277	+
278	+	if cs.consecutiveFailures >= l.alertThreshold && !cs.alerted {
279	+		cs.alerted = true
280	+		if l.alertFn != nil {
281	+			l.alertFn(clientID, cs.consecutiveFailures)
282	+		}
283	+	}
284	+}
285	+
286	+// advance increments key's consecutive-failure count and sets its
287	+// exponential backoff window, creating the state entry if this is its
288	+// first failure.
289	+func (l *InProcessGrantLimiter) advance(m map[string]*grantBackoffState, key string, now time.Time) *grantBackoffState {
290	+	s, ok := m[key]
291	+	if !ok {
292	+		s = &grantBackoffState{}
293	+		m[key] = s
294	+	}
295	+	s.consecutiveFailures++
296	+	backoff := l.baseBackoff << uint(s.consecutiveFailures-1) // 2^(n-1) * base
297	+	if backoff > l.maxBackoff || backoff <= 0 {               // overflow guard: a left-shifted duration can wrap negative
298	+		backoff = l.maxBackoff
299	+	}
300	+	s.blockedUntil = now.Add(backoff)
301	+	return s
302	+}
303	+
304	+func (l *InProcessGrantLimiter) RecordSuccess(_ context.Context, clientID, sourceIP string) {
305	+	l.mu.Lock()
306	+	defer l.mu.Unlock()
307	+	delete(l.byClientID, clientID)
308	+	delete(l.bySourceIP, sourceIP)
309	+}
310	diff --git a/internal/api/grantlimiter_test.go b/internal/api/grantlimiter_test.go
311	new file mode 100644
312	index 0000000..5b6185e
313	--- /dev/null
314	+++ b/internal/api/grantlimiter_test.go
315	@@ -0,0 +1,185 @@
316	+package api
317	+
318	+import (
319	+	"context"
320	+	"testing"
321	+	"time"
322	+)
323	+
324	+func TestGrantLimiter_AllowsFirstAttempt(t *testing.T) {
325	+	l := NewInProcessGrantLimiter(time.Second, time.Minute, 5, nil)
326	+	allow, err := l.Allow(context.Background(), "client-a", "«IP»")
327	+	if err != nil || !allow {
328	+		t.Fatalf("Allow = %v, %v; want true, nil", allow, err)
329	+	}
330	+}
331	+
332	+func TestGrantLimiter_BlocksAfterFailure(t *testing.T) {
333	+	l := NewInProcessGrantLimiter(time.Minute, time.Hour, 5, nil)
334	+	ctx := context.Background()
335	+	l.RecordFailure(ctx, "client-a", "«IP»")
336	+
337	+	allow, err := l.Allow(ctx, "client-a", "«IP»")
338	+	if err != nil {
339	+		t.Fatalf("Allow: %v", err)
340	+	}
341	+	if allow {
342	+		t.Error("should be blocked immediately after a failure, within the backoff window")
343	+	}
344	+}
345	+
346	+func TestGrantLimiter_BackoffIsExponential(t *testing.T) {
347	+	l := NewInProcessGrantLimiter(time.Second, time.Hour, 100, nil)
348	+	fakeNow := time.Now()
349	+	l.now = func() time.Time { return fakeNow }
350	+	ctx := context.Background()
351	+
352	+	l.RecordFailure(ctx, "client-a", "«IP»")
353	+	// First failure: 1s backoff. Advance 1.5s — should be allowed again.
354	+	fakeNow = fakeNow.Add(1500 * time.Millisecond)
355	+	if allow, _ := l.Allow(ctx, "client-a", "«IP»"); !allow {
356	+		t.Fatal("should be allowed again after the first (1s) backoff elapses")
357	+	}
358	+
359	+	l.RecordFailure(ctx, "client-a", "«IP»")
360	+	// Second consecutive failure: 2s backoff. Advance only 1.5s again —
361	+	// should still be blocked, proving the window grew.
362	+	fakeNow = fakeNow.Add(1500 * time.Millisecond)
363	+	if allow, _ := l.Allow(ctx, "client-a", "«IP»"); allow {
364	+		t.Error("should still be blocked — the second failure's backoff (2s) hasn't elapsed yet")
365	+	}
366	+}
367	+
368	+func TestGrantLimiter_BackoffCappedAtMax(t *testing.T) {
369	+	l := NewInProcessGrantLimiter(time.Second, 5*time.Second, 100, nil)
370	+	fakeNow := time.Now()
371	+	l.now = func() time.Time { return fakeNow }
372	+	ctx := context.Background()
373	+
374	+	for i := 0; i < 10; i++ {
375	+		l.RecordFailure(ctx, "client-a", "«IP»")
376	+	}
377	+	// Even after many failures, the backoff must not exceed maxBackoff.
378	+	fakeNow = fakeNow.Add(5*time.Second + time.Millisecond)
379	+	if allow, _ := l.Allow(ctx, "client-a", "«IP»"); !allow {
380	+		t.Error("backoff should be capped at maxBackoff (5s), not grow unbounded across 10 failures")
381	+	}
382	+}
383	+
384	+func TestGrantLimiter_KeyedIndependentlyByClientIDAndSourceIP(t *testing.T) {
385	+	l := NewInProcessGrantLimiter(time.Hour, time.Hour, 100, nil)
386	+	ctx := context.Background()
387	+
388	+	// A distributed brute force: many source IPs against one client_id.
389	+	// The client_id key alone must block a request even from a fresh IP.
390	+	l.RecordFailure(ctx, "client-a", "«IP»")
391	+	if allow, _ := l.Allow(ctx, "client-a", "«IP»"); allow {
392	+		t.Error("a blocked client_id must block attempts from a different source IP too")
393	+	}
394	+
395	+	// A single-source credential-stuffing run: many client_ids from one
396	+	// IP. The source-IP key alone must block a fresh client_id from that
397	+	// same IP.
398	+	if allow, _ := l.Allow(ctx, "client-b", "«IP»"); allow {
399	+		t.Error("a blocked source IP must block attempts for a different client_id too")
400	+	}
401	+
402	+	// A genuinely unrelated (client_id, IP) pair must not be affected.
403	+	if allow, _ := l.Allow(ctx, "client-c", "«IP»"); !allow {
404	+		t.Error("an unrelated client_id/source IP pair must not be blocked")
405	+	}
406	+}
407	+
408	+func TestGrantLimiter_SuccessResetsBackoff(t *testing.T) {
409	+	l := NewInProcessGrantLimiter(time.Hour, time.Hour, 100, nil)
410	+	ctx := context.Background()
411	+
412	+	l.RecordFailure(ctx, "client-a", "«IP»")
413	+	if allow, _ := l.Allow(ctx, "client-a", "«IP»"); allow {
414	+		t.Fatal("should be blocked after a failure")
415	+	}
416	+
417	+	l.RecordSuccess(ctx, "client-a", "«IP»")
418	+	if allow, _ := l.Allow(ctx, "client-a", "«IP»"); !allow {
419	+		t.Error("a successful grant should reset backoff state entirely")
420	+	}
421	+}
422	+
423	+// TestGrantLimiter_AlertsOnceOnSustainedFailureRun is
424	+// handoff-03-auth.md v3's "alerting on a sustained run of failed grants
425	+// against one client_id" — fired exactly once per threshold crossing,
426	+// not once per failure past it (which would just be alert-fatigue noise
427	+// indistinguishable from the failures themselves).
428	+func TestGrantLimiter_AlertsOnceOnSustainedFailureRun(t *testing.T) {
429	+	var alerts []int
430	+	l := NewInProcessGrantLimiter(time.Nanosecond, time.Nanosecond, 3, func(clientID string, consecutiveFailures int) {
431	+		alerts = append(alerts, consecutiveFailures)
432	+	})
433	+	fakeNow := time.Now()
434	+	l.now = func() time.Time { return fakeNow }
435	+	ctx := context.Background()
436	+
437	+	for i := 0; i < 6; i++ {
438	+		l.RecordFailure(ctx, "client-a", "«IP»")
439	+		fakeNow = fakeNow.Add(time.Millisecond) // clear the (nanosecond) backoff each time
440	+	}
441	+
442	+	if len(alerts) != 1 {
443	+		t.Fatalf("alerts fired = %v, want exactly one (at the threshold crossing)", alerts)
444	+	}
445	+	if alerts[0] != 3 {
446	+		t.Errorf("alert fired at %d consecutive failures, want at the threshold (3)", alerts[0])
447	+	}
44
… truncated (20 KB cap)
Sep 13, 4:47 AM PDTOren Castellan
Read /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706e
input
{
  "file_path": "/private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr30.diff",
  "offset": 1159,
  "limit": 700
}
result
1159	+		t.Fatalf("priming: %v", err)
1160	+	}
1161	+	requestsAfterPriming := requests.Load()
1162	+
1163	+	// Two lookups for an unknown kid in quick succession (same fake
1164	+	// "now") must trigger at most one extra refetch between them, not
1165	+	// one per lookup — that's the rate limit closing the "unknown-kid
1166	+	// flood hammers the issuer" gap.
1167	+	_, _ = cache.KeyForKid(ctx, "no-such-kid")
1168	+	afterFirstUnknown := requests.Load()
1169	+	_, _ = cache.KeyForKid(ctx, "no-such-kid")
1170	+	afterSecondUnknown := requests.Load()
1171	+
1172	+	if afterFirstUnknown != requestsAfterPriming+1 {
1173	+		t.Errorf("first unknown-kid lookup triggered %d extra fetch(es), want exactly 1", afterFirstUnknown-requestsAfterPriming)
1174	+	}
1175	+	if afterSecondUnknown != afterFirstUnknown {
1176	+		t.Errorf("second unknown-kid lookup (within the rate-limit window) triggered an extra fetch: %d -> %d, want no change", afterFirstUnknown, afterSecondUnknown)
1177	+	}
1178	+}
1179	diff --git a/internal/api/jwtmiddleware.go b/internal/api/jwtmiddleware.go
1180	new file mode 100644
1181	index 0000000..5bf0871
1182	--- /dev/null
1183	+++ b/internal/api/jwtmiddleware.go
1184	@@ -0,0 +1,243 @@
1185	+// jwtmiddleware.go is LT-40's core: verify the client-credentials JWT
1186	+// scheme from handoff-03-auth.md v3/v4 and populate AuthContext for
1187	+// LT-39's already-built handlers. This is the actual integration seam
1188	+// with S6's work, not new response-shape design — a verification failure
1189	+// uses the exact same writeUnauthorized this package's handlers already
1190	+// call for a missing AuthContext.
1191	+package api
1192	+
1193	+import (
1194	+	"context"
1195	+	"errors"
1196	+	"net/http"
1197	+	"strings"
1198	+	"time"
1199	+
1200	+	"github.com/golang-jwt/jwt/v5"
1201	+)
1202	+
1203	+// jwtAlgorithm is the one signing algorithm this verifier ever accepts.
1204	+// Passed explicitly to jwt.WithValidMethods on every parse — never left
1205	+// to the library's default, per handoff-03-auth.md v3's explicit
1206	+// instruction ("check your JWT library's default behavior — several
1207	+// popular libraries have shipped this vulnerability by not doing this by
1208	+// default") and RFC 8725 §3.1. This is what makes alg:none and an
1209	+// HMAC-with-the-public-key-as-secret downgrade both fail before the
1210	+// library ever calls KeySource — jwt/v5's WithValidMethods rejects a
1211	+// token whose header alg isn't in this allowlist ahead of signature
1212	+// verification, not as a secondary check after.
1213	+const jwtAlgorithm = "RS256"
1214	+
1215	+// claims is this service's JWT claim set (handoff-03-auth.md v3's
1216	+// minimum: sub, scope, exp, iat, aud, plus iss and kid via the header).
1217	+// RegisteredClaims gives exp/iat/iss/aud validation for free from the
1218	+// library — this verifier still explicitly re-checks Audience below
1219	+// because jwt/v5's built-in audience check (via WithAudience) does not
1220	+// distinguish "no aud claim at all" from "aud present but empty," and a
1221	+// token with no audience claim must not silently pass as if it matched.
1222	+type claims struct {
1223	+	Scope string `json:"scope"`
1224	+	jwt.RegisteredClaims
1225	+}
1226	+
1227	+// jwtVerifier is the collaborator NewJWTMiddleware wraps — an interface
1228	+// so a test can supply a fake instead of a real *JWKSCache doing network
1229	+// I/O, mirroring this package's existing test-double approach
1230	+// (decisions/test-double-strategy.md).
1231	+type jwtVerifier struct {
1232	+	keys     KeySource
1233	+	issuer   string
1234	+	audience string
1235	+}
1236	+
1237	+// verify parses and fully validates tokenString, returning the populated
1238	+// AuthContext on success. Every failure returns a generic error — the
1239	+// specific reason (expired, wrong audience, bad signature, unknown kid,
1240	+// disallowed algorithm) is never distinguishable to the caller of this
1241	+// function beyond "verification failed," which is what makes
1242	+// writeUnauthorized's single, undifferentiated 401 body correct here
1243	+// without an extra translation step.
1244	+func (v *jwtVerifier) verify(ctx context.Context, tokenString string) (AuthContext, error) {
1245	+	var c claims
1246	+	token, err := jwt.ParseWithClaims(tokenString, &c, func(t *jwt.Token) (any, error) {
1247	+		kid, _ := t.Header["kid"].(string)
1248	+		if kid == "" {
1249	+			return nil, errors.New("token header has no kid")
1250	+		}
1251	+		return v.keys.KeyForKid(ctx, kid)
1252	+	}, jwt.WithValidMethods([]string{jwtAlgorithm}), jwt.WithIssuer(v.issuer))
1253	+	if err != nil {
1254	+		return AuthContext{}, err
1255	+	}
1256	+	if !token.Valid {
1257	+		return AuthContext{}, errors.New("token invalid")
1258	+	}
1259	+
1260	+	// Audience checked explicitly, not via jwt.WithAudience above: v5's
1261	+	// audience option treats an all-nil/absent Audience claim
1262	+	// inconsistently across versions in a way we don't want to depend
1263	+	// on — asserting it here directly means this check's behavior is
1264	+	// this file's own to guarantee, not the library's default to trust.
1265	+	if !hasAudience(c.Audience, v.audience) {
1266	+		return AuthContext{}, errors.New("token audience mismatch")
1267	+	}
1268	+
1269	+	if c.Subject == "" {
1270	+		return AuthContext{}, errors.New("token has no sub claim")
1271	+	}
1272	+
1273	+	scope, err := singleScope(c.Scope)
1274	+	if err != nil {
1275	+		return AuthContext{}, err
1276	+	}
1277	+
1278	+	return AuthContext{Sub: c.Subject, Scope: scope}, nil
1279	+}
1280	+
1281	+func hasAudience(aud jwt.ClaimStrings, want string) bool {
1282	+	for _, a := range aud {
1283	+		if a == want {
1284	+			return true
1285	+		}
1286	+	}
1287	+	return false
1288	+}
1289	+
1290	+// singleScope requires the token's scope claim to name exactly one
1291	+// distinct recognized scope. handoff-03-auth.md v3 describes the claim
1292	+// as "space-delimited, values below" without describing how a handler
1293	+// disambiguates a multi-scope token per request, and AuthContext (LT-39)
1294	+// carries exactly one Scope by design ("handoff-03-auth.md v3 doesn't
1295	+// describe multi-scope tokens being disambiguated per-request, so one
1296	+// request carries exactly one Scope"). Rather than guess which of
1297	+// several granted scopes a request is "really" using — silently picking
1298	+// one is exactly the kind of authorization ambiguity this project's
1299	+// contracts have consistently ruled against — a token naming zero or
1300	+// more than one DISTINCT recognized scope is rejected outright. This
1301	+// matches the provisioning model in decisions/search-authz-scoping.md: a
1302	+// client is provisioned for the one use case its credential exists for.
1303	+//
1304	+// Deduplicated by distinct value, not by occurrence count: a malformed
1305	+// but unambiguous claim like "profile:search profile:search" names one
1306	+// scope repeated, not two different ones to disambiguate between, and
1307	+// rejecting it as "more than one" would contradict this function's own
1308	+// reasoning above (Oren Castellan, PR #30 review).
1309	+func singleScope(raw string) (Scope, error) {
1310	+	fields := strings.Fields(raw)
1311	+	found := make(map[Scope]bool, len(fields))
1312	+	for _, f := range fields {
1313	+		switch Scope(f) {
1314	+		case ScopeReadOwn, ScopeReadAny, ScopeSearch:
1315	+			found[Scope(f)] = true
1316	+		}
1317	+	}
1318	+	switch len(found) {
1319	+	case 0:
1320	+		return "", errors.New("token scope claim names no recognized scope")
1321	+	case 1:
1322	+		for s := range found {
1323	+			return s, nil
1324	+		}
1325	+		panic("unreachable")
1326	+	default:
1327	+		return "", errors.New("token scope claim names more than one distinct recognized scope, which this verifier does not disambiguate per request")
1328	+	}
1329	+}
1330	+
1331	+// NewJWTMiddleware verifies every request's bearer token and populates
1332	+// AuthContext on success, per handoff-03-auth.md v3/v4: public-key-only
1333	+// verification (keys never includes signing-key material — enforced
1334	+// structurally, since KeySource's real implementation, JWKSCache, only
1335	+// ever holds *rsa.PublicKey values parsed from a JWKS response), pinned
1336	+// RS256, kid-based key selection supporting a rotation overlap window (a
1337	+// KeySource backed by the issuer's full published key set, not a single
1338	+// cached key), and issuer/audience checks. Verification failure returns
1339	+// 401 via writeUnauthorized — the same path LT-39's handlers already use
1340	+// for a missing AuthContext, not a new response shape — with one
1341	+// exception: a KeySource failing closed on exceeded JWKS staleness
1342	+// (ErrJWKSStale) logs AuditJWKSUnavailable first, since that's a
1343	+// token-infrastructure degradation, not an ordinary bad-credential
1344	+// attempt, and worth distinguishing in the audit trail (Tomasz Wrede, 02
1345	+// cold review). audit may be nil (tests that don't care about audit
1346	+// output); production wiring always passes a real AuditLogger.
1347	+func NewJWTMiddleware(keys KeySource, issuer, audience string, audit AuditLogger) func(http.Handler) http.Handler {
1348	+	v := &jwtVerifier{keys: keys, issuer: issuer, audience: audience}
1349	+	return func(next http.Handler) http.Handler {
1350	+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1351	+			ctx := r.Context()
1352	+			tokenString, ok := bearerToken(r)
1353	+			if !ok {
1354	+				logAuthnFailure(ctx, audit, nil)
1355	+				writeUnauthorized(w)
1356	+				return
1357	+			}
1358	+			ac, err := v.verify(ctx, tokenString)
1359	+			if err != nil {
1360	+				logAuthnFailure(ctx, audit, err)
1361	+				writeUnauthorized(w)
1362	+				return
1363	+			}
1364	+			r = r.WithContext(WithAuthContext(ctx, ac))
1365	+			next.ServeHTTP(w, r)
1366	+		})
1367	+	}
1368	+}
1369	+
1370	+func logAuthnFailure(ctx context.Context, audit AuditLogger, err error) {
1371	+	if audit == nil {
1372	+		return
1373	+	}
1374	+	kind := AuditAuthnFailure
1375	+	if errors.Is(err, ErrJWKSStale) {
1376	+		kind = AuditJWKSUnavailable
1377	+	}
1378	+	audit.Log(ctx, AuditEvent{Kind: kind, Timestamp: time.Now().UTC()})
1379	+}
1380	+
1381	+// bearerToken's scheme match is case-insensitive ("Bearer", "bearer",
1382	+// "BEARER" all accepted) per RFC 7235 §2.1, which defines the
1383	+// auth-scheme token as case-insensitive — a prior case-sensitive match
1384	+// would 401 a structurally valid request from any client that happened
1385	+// to send a lowercase scheme (Oren Castellan, PR #30 review).
1386	+func bearerToken(r *http.Request) (string, bool) {
1387	+	h := r.Header.Get("Authorization")
1388	+	const prefixLen = len("Bearer ")
1389	+	if len(h) <= prefixLen || !strings.EqualFold(h[:prefixLen], "Bearer ") {
1390	+		return "", false
1391	+	}
1392	+	tok := strings.TrimSpace(h[prefixLen:])
1393	+	if tok == "" {
1394	+		return "", false
1395	+	}
1396	+	return tok, true
1397	+}
1398	+
1399	+// deadlineBudget is the context.WithTimeout duration this middleware
1400	+// applies to every request before it reaches a handler — the concrete
1401	+// number decisions/context-propagation.md's ruling required ("this gets
1402	+// a concrete number attached when [the middleware] is written, not left
1403	+// as 'some deadline'"). Sized to cover realistic p99 DAO latency
1404	+// (single-digit milliseconds for an indexed Postgres/CockroachDB point
1405	+// query or trigram search, per 05-data-ops/research-cockroachdb-postgres-semantics.md)
1406	+// plus this middleware's own JWKS verification path (a cache hit is
1407	+// sub-millisecond; a cache-miss fetch to the issuer's in-cluster Service
1408	+// is bounded by deadlineBudget itself, not a separate timeout, since it
1409	+// happens inside the same request). 5 seconds leaves an order of
1410	+// magnitude of headroom over expected p99 without ever handing the DAO
1411	+// layer a near-zero remaining deadline as a matter of routine operation.
1412	+const deadlineBudget = 5 * time.Second
1413	+
1414	+// NewDeadlineMiddleware applies deadlineBudget to every request's
1415	+// context before it reaches a handler — the enforcement site
1416	+// decisions/context-propagation.md's ruling named ("a concrete number
1417	+// attached when [the middleware] is written"). Composed outermost (see
1418	+// router wiring), so the budget covers JWT verification (including a
1419	+// JWKS cache-miss fetch) and the handler's own DAO call under one
1420	+// deadline, not two independently-sized timeouts that could disagree.
1421	+func NewDeadlineMiddleware(next http.Handler) http.Handler {
1422	+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1423	+		ctx, cancel := context.WithTimeout(r.Context(), deadlineBudget)
1424	+		defer cancel()
1425	+		next.ServeHTTP(w, r.WithContext(ctx))
1426	+	})
1427	+}
1428	diff --git a/internal/api/jwtmiddleware_test.go b/internal/api/jwtmiddleware_test.go
1429	new file mode 100644
1430	index 0000000..8649224
1431	--- /dev/null
1432	+++ b/internal/api/jwtmiddleware_test.go
1433	@@ -0,0 +1,353 @@
1434	+package api
1435	+
1436	+import (
1437	+	"context"
1438	+	"crypto/rand"
1439	+	"crypto/rsa"
1440	+	"crypto/x509"
1441	+	"net/http"
1442	+	"net/http/httptest"
1443	+	"testing"
1444	+	"time"
1445	+
1446	+	"github.com/golang-jwt/jwt/v5"
1447	+)
1448	+
1449	+const (
1450	+	testIssuer   = "https://auth.loginid-takehome.internal"
1451	+	testAudience = "loginid-api-service"
1452	+)
1453	+
1454	+// fakeKeySource implements KeySource with an in-memory map, for tests
1455	+// that don't need real JWKS-over-HTTP fetching (that's jwks_test.go's
1456	+// job).
1457	+type fakeKeySource struct {
1458	+	keys map[string]*rsa.PublicKey
1459	+}
1460	+
1461	+func (f *fakeKeySource) KeyForKid(_ context.Context, kid string) (*rsa.PublicKey, error) {
1462	+	k, ok := f.keys[kid]
1463	+	if !ok {
1464	+		return nil, errNoKey
1465	+	}
1466	+	return k, nil
1467	+}
1468	+
1469	+var errNoKey = jwt.ErrTokenUnverifiable
1470	+
1471	+func generateRSAKeyPair(t *testing.T) *rsa.PrivateKey {
1472	+	t.Helper()
1473	+	key, err := rsa.GenerateKey(rand.Reader, 2048)
1474	+	if err != nil {
1475	+		t.Fatalf("generating RSA key: %v", err)
1476	+	}
1477	+	return key
1478	+}
1479	+
1480	+type tokenOpts struct {
1481	+	kid      string
1482	+	alg      jwt.SigningMethod
1483	+	issuer   string
1484	+	audience string
1485	+	subject  string
1486	+	scope    string
1487	+	expired  bool
1488	+	notYet   bool
1489	+}
1490	+
1491	+func signToken(t *testing.T, key *rsa.PrivateKey, opts tokenOpts) string {
1492	+	t.Helper()
1493	+	now := time.Now()
1494	+	exp := now.Add(10 * time.Minute)
1495	+	if opts.expired {
1496	+		exp = now.Add(-1 * time.Minute)
1497	+	}
1498	+	iat := now
1499	+	nbf := now
1500	+	if opts.notYet {
1501	+		nbf = now.Add(1 * time.Hour)
1502	+	}
1503	+	c := claims{
1504	+		Scope: opts.scope,
1505	+		RegisteredClaims: jwt.RegisteredClaims{
1506	+			Issuer:    opts.issuer,
1507	+			Subject:   opts.subject,
1508	+			Audience:  jwt.ClaimStrings{opts.audience},
1509	+			ExpiresAt: jwt.NewNumericDate(exp),
1510	+			IssuedAt:  jwt.NewNumericDate(iat),
1511	+			NotBefore: jwt.NewNumericDate(nbf),
1512	+		},
1513	+	}
1514	+	token := jwt.NewWithClaims(opts.alg, c)
1515	+	token.Header["kid"] = opts.kid
1516	+	signed, err := token.SignedString(key)
1517	+	if err != nil {
1518	+		t.Fatalf("signing token: %v", err)
1519	+	}
1520	+	return signed
1521	+}
1522	+
1523	+func defaultOpts(kid string) tokenOpts {
1524	+	return tokenOpts{
1525	+		kid:      kid,
1526	+		alg:      jwt.SigningMethodRS256,
1527	+		issuer:   testIssuer,
1528	+		audience: testAudience,
1529	+		subject:  "client-under-test",
1530	+		scope:    string(ScopeSearch),
1531	+	}
1532	+}
1533	+
1534	+func newTestMiddleware(keys map[string]*rsa.PublicKey) func(http.Handler) http.Handler {
1535	+	return NewJWTMiddleware(&fakeKeySource{keys: keys}, testIssuer, testAudience, &fakeAuditLogger{})
1536	+}
1537	+
1538	+func okHandler(gotCtx *AuthContext) http.Handler {
1539	+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1540	+		if ac, ok := AuthContextFromContext(r.Context()); ok {
1541	+			*gotCtx = ac
1542	+		}
1543	+		w.WriteHeader(http.StatusOK)
1544	+	})
1545	+}
1546	+
1547	+func doRequest(mw func(http.Handler) http.Handler, token string) (*httptest.ResponseRecorder, AuthContext) {
1548	+	var gotCtx AuthContext
1549	+	h := mw(okHandler(&gotCtx))
1550	+	req := httptest.NewRequest(http.MethodGet, "/profiles/search", nil)
1551	+	if token != "" {
1552	+		req.Header.Set("Authorization", "Bearer "+token)
1553	+	}
1554	+	w := httptest.NewRecorder()
1555	+	h.ServeHTTP(w, req)
1556	+	return w, gotCtx
1557	+}
1558	+
1559	+func TestJWTMiddleware_ValidTokenPopulatesAuthContext(t *testing.T) {
1560	+	key := generateRSAKeyPair(t)
1561	+	mw := newTestMiddleware(map[string]*rsa.PublicKey{"key-1": &key.PublicKey})
1562	+	tok := signToken(t, key, defaultOpts("key-1"))
1563	+
1564	+	w, ac := doRequest(mw, tok)
1565	+	if w.Code != http.StatusOK {
1566	+		t.Fatalf("status = %d, want 200", w.Code)
1567	+	}
1568	+	if ac.Sub != "client-under-test" || ac.Scope != ScopeSearch {
1569	+		t.Errorf("AuthContext = %+v, want Sub=client-under-test Scope=profile:search", ac)
1570	+	}
1571	+}
1572	+
1573	+func TestJWTMiddleware_MissingToken_401(t *testing.T) {
1574	+	key := generateRSAKeyPair(t)
1575	+	mw := newTestMiddleware(map[string]*rsa.PublicKey{"key-1": &key.PublicKey})
1576	+	w, _ := doRequest(mw, "")
1577	+	if w.Code != http.StatusUnauthorized {
1578	+		t.Errorf("status = %d, want 401", w.Code)
1579	+	}
1580	+}
1581	+
1582	+func TestJWTMiddleware_ExpiredToken_401(t *testing.T) {
1583	+	key := generateRSAKeyPair(t)
1584	+	mw := newTestMiddleware(map[string]*rsa.PublicKey{"key-1": &key.PublicKey})
1585	+	opts := defaultOpts("key-1")
1586	+	opts.expired = true
1587	+	tok := signToken(t, key, opts)
1588	+
1589	+	w, _ := doRequest(mw, tok)
1590	+	if w.Code != http.StatusUnauthorized {
1591	+		t.Errorf("status = %d, want 401 for an expired token", w.Code)
1592	+	}
1593	+}
1594	+
1595	+func TestJWTMiddleware_WrongAudience_401(t *testing.T) {
1596	+	key := generateRSAKeyPair(t)
1597	+	mw := newTestMiddleware(map[string]*rsa.PublicKey{"key-1": &key.PublicKey})
1598	+	opts := defaultOpts("key-1")
1599	+	opts.audience = "some-other-service"
1600	+	tok := signToken(t, key, opts)
1601	+
1602	+	w, _ := doRequest(mw, tok)
1603	+	if w.Code != http.StatusUnauthorized {
1604	+		t.Errorf("status = %d, want 401 for a mismatched audience", w.Code)
1605	+	}
1606	+}
1607	+
1608	+func TestJWTMiddleware_WrongIssuer_401(t *testing.T) {
1609	+	key := generateRSAKeyPair(t)
1610	+	mw := newTestMiddleware(map[string]*rsa.PublicKey{"key-1": &key.PublicKey})
1611	+	opts := defaultOpts("key-1")
1612	+	opts.issuer = "https://not-the-real-issuer.example"
1613	+	tok := signToken(t, key, opts)
1614	+
1615	+	w, _ := doRequest(mw, tok)
1616	+	if w.Code != http.StatusUnauthorized {
1617	+		t.Errorf("status = %d, want 401 for a mismatched issuer", w.Code)
1618	+	}
1619	+}
1620	+
1621	+// TestJWTMiddleware_AlgNone_401 is RFC 8725 §3.1's classic attack: a
1622	+// token whose header claims alg:none, carrying no signature at all. The
1623	+// algorithm-allowlist passed to jwt.ParseWithClaims must reject this
1624	+// before ever reaching the keyfunc/signature-verification step.
1625	+func TestJWTMiddleware_AlgNone_401(t *testing.T) {
1626	+	mw := newTestMiddleware(map[string]*rsa.PublicKey{})
1627	+
1628	+	unsignedClaims := claims{
1629	+		Scope: string(ScopeSearch),
1630	+		RegisteredClaims: jwt.RegisteredClaims{
1631	+			Issuer:    testIssuer,
1632	+			Subject:   "attacker",
1633	+			Audience:  jwt.ClaimStrings{testAudience},
1634	+			ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
1635	+		},
1636	+	}
1637	+	tok := jwt.NewWithClaims(jwt.SigningMethodNone, unsignedClaims)
1638	+	signed, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
1639	+	
… truncated (20 KB cap)
Sep 13, 4:47 AM PDTOren Castellan
Read /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706e
input
{
  "file_path": "/private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr30.diff",
  "offset": 1859,
  "limit": 455
}
result
1859	+// TestStopgapAuthorizer_ReadOwn_AllowedAndDenied is Marcus Ilori's (02)
1860	+// ruling on the review finding that this stand-in's deny path had no
1861	+// test coverage: exercise both the allow and deny outcomes for
1862	+// ScopeReadOwn, the only scope this stopgap actually gates.
1863	+func TestStopgapAuthorizer_ReadOwn_AllowedAndDenied(t *testing.T) {
1864	+	a := &StopgapAuthorizer{
1865	+		ReadOwnAllow: map[string]map[string]bool{
1866	+			"qa-client": {"profile-1": true},
1867	+		},
1868	+	}
1869	+	ctx := context.Background()
1870	+
1871	+	allowed, err := a.Authorize(ctx, "qa-client", ScopeReadOwn, "profile-1")
1872	+	if err != nil {
1873	+		t.Fatalf("Authorize: %v", err)
1874	+	}
1875	+	if !allowed {
1876	+		t.Error("qa-client reading its seeded profile-1 should be allowed")
1877	+	}
1878	+
1879	+	denied, err := a.Authorize(ctx, "qa-client", ScopeReadOwn, "profile-2")
1880	+	if err != nil {
1881	+		t.Fatalf("Authorize: %v", err)
1882	+	}
1883	+	if denied {
1884	+		t.Error("qa-client reading a profile it was not seeded with should be denied")
1885	+	}
1886	+
1887	+	deniedSub, err := a.Authorize(ctx, "some-other-client", ScopeReadOwn, "profile-1")
1888	+	if err != nil {
1889	+		t.Fatalf("Authorize: %v", err)
1890	+	}
1891	+	if deniedSub {
1892	+		t.Error("a sub with no seeded entry at all should be denied, the safe default")
1893	+	}
1894	+}
1895	+
1896	+// TestStopgapAuthorizer_EmptyMapDeniesEverything is the safe-default
1897	+// requirement itself: a StopgapAuthorizer constructed with no seed data
1898	+// (the out-of-the-box state before LT-51 seeds a QA client credential)
1899	+// must deny every ScopeReadOwn request, never fail open.
1900	+func TestStopgapAuthorizer_EmptyMapDeniesEverything(t *testing.T) {
1901	+	a := &StopgapAuthorizer{}
1902	+	allowed, err := a.Authorize(context.Background(), "anyone", ScopeReadOwn, "anything")
1903	+	if err != nil {
1904	+		t.Fatalf("Authorize: %v", err)
1905	+	}
1906	+	if allowed {
1907	+		t.Error("an unseeded StopgapAuthorizer must deny every read:own request, not fail open")
1908	+	}
1909	+}
1910	+
1911	+func TestStopgapAuthorizer_ReadAnyAndSearch_AlwaysAllowed(t *testing.T) {
1912	+	a := &StopgapAuthorizer{}
1913	+	ctx := context.Background()
1914	+
1915	+	if allowed, _ := a.Authorize(ctx, "anyone", ScopeReadAny, "anything"); !allowed {
1916	+		t.Error("read:any has no per-record referral concept this stopgap gates — should not be denied here")
1917	+	}
1918	+	if allowed, _ := a.Authorize(ctx, "anyone", ScopeSearch, ""); !allowed {
1919	+		t.Error("search has no per-record target this stopgap gates — should not be denied here")
1920	+	}
1921	+}
1922	diff --git a/internal/app/verifier_router.go b/internal/app/verifier_router.go
1923	new file mode 100644
1924	index 0000000..2d890bd
1925	--- /dev/null
1926	+++ b/internal/app/verifier_router.go
1927	@@ -0,0 +1,90 @@
1928	+package app
1929	+
1930	+import (
1931	+	"net/http"
1932	+	"time"
1933	+
1934	+	"loginid-takehome/internal/api"
1935	+	"loginid-takehome/internal/config"
1936	+	"loginid-takehome/internal/dao"
1937	+)
1938	+
1939	+// jwksCacheTTL matches the token TTL per handoff-03-auth.md v4's own
1940	+// guidance ("Verifier caches the fetched key set in memory with a short
1941	+// TTL (matching token TTL is reasonable)") — using the upper bound of the
1942	+// 5-15 minute TTL range so a cache refresh never fires more often than a
1943	+// token itself could have already expired and been re-issued.
1944	+const jwksCacheTTL = 15 * time.Minute
1945	+
1946	+// NewVerifierRouter builds api-service's real, authenticated router —
1947	+// LT-39's handlers (search/retrieve) wired behind LT-40's JWT
1948	+// verification middleware, plus /healthz. This is the ModeVerifier
1949	+// Deployment's router; the issuer Deployment (ModeIssuer) still uses the
1950	+// plain NewRouter above, since it has no need for any of this — it never
1951	+// verifies inbound tokens, only issues them.
1952	+//
1953	+// repo may be nil — cmd/api-service/main.go passes nil rather than
1954	+// crash-looping the whole process when the DAO repository couldn't be
1955	+// constructed at startup (no DB_DRIVER/DB_DSN yet, e.g. before the
1956	+// database exists in this environment). /healthz must stay reachable
1957	+// regardless (LT-34's live-URL story; LT-52 is what teaches /healthz to
1958	+// actually report DB status, not this function); the two protected
1959	+// routes fail closed with 503 instead of panicking on a nil Repository
1960	+// or silently pretending to work. This is a startup-shape decision, not
1961	+// an ongoing health check — a repo that stops answering after a
1962	+// successful open is /healthz's job to surface (LT-52), not this
1963	+// router's.
1964	+func NewVerifierRouter(cfg config.Config, repo dao.Repository) http.Handler {
1965	+	mux := http.NewServeMux()
1966	+	mux.HandleFunc("/healthz", healthHandler("api-service"))
1967	+
1968	+	auditLog := api.StdoutAuditLogger{}
1969	+	keys := api.NewJWKSCache(cfg.AuthJWKSURL, jwksCacheTTL, nil)
1970	+	authMiddleware := api.NewJWTMiddleware(keys, cfg.AuthJWTIssuer, cfg.AuthJWTAudience, auditLog)
1971	+
1972	+	// api.NewDeadlineMiddleware outermost: the deadline budget must cover
1973	+	// JWT verification (including a JWKS cache-miss fetch) as well as
1974	+	// the handler's own DAO call, per decisions/context-propagation.md's
1975	+	// ruling — a single deadline, not the middleware's and the handler's
1976	+	// own disagreeing.
1977	+	protect := func(h http.Handler) http.Handler {
1978	+		return api.NewDeadlineMiddleware(authMiddleware(h))
1979	+	}
1980	+
1981	+	var searchHandler, getHandler http.Handler
1982	+	if repo == nil {
1983	+		searchHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { api.WriteServiceUnavailable(w) })
1984	+		getHandler = searchHandler
1985	+	} else {
1986	+		deps := api.Deps{
1987	+			Repo:        repo,
1988	+			RateLimiter: api.NewInProcessRateLimiter(),
1989	+			TouchCounter: api.NewInProcessTouchCounter(
1990	+				api.TouchCapPerWindow, api.TouchCapWindow),
1991	+			AuditLog: auditLog,
1992	+			Authz: &api.StopgapAuthorizer{
1993	+				ReadOwnAllow: stopgapReadOwnAllow(cfg),
1994	+			},
1995	+		}
1996	+		searchHandler = api.NewSearchHandler(deps)
1997	+		getHandler = api.NewGetProfileHandler(deps)
1998	+	}
1999	+
2000	+	mux.Handle("POST /profiles/search", protect(searchHandler))
2001	+	mux.Handle("GET /profiles/{id}", protect(getHandler))
2002	+
2003	+	return mux
2004	+}
2005	+
2006	+// stopgapReadOwnAllow builds StopgapAuthorizer's hard-coded (sub ->
2007	+// allowed record id) map from cfg — empty (denies everything) unless
2008	+// both AuthzQASub and AuthzQAAllowedProfileID are set, which LT-51 does
2009	+// once the QA client credential and a matching seeded profile exist.
2010	+func stopgapReadOwnAllow(cfg config.Config) map[string]map[string]bool {
2011	+	if cfg.AuthzQASub == "" || cfg.AuthzQAAllowedProfileID == "" {
2012	+		return nil
2013	+	}
2014	+	return map[string]map[string]bool{
2015	+		cfg.AuthzQASub: {cfg.AuthzQAAllowedProfileID: true},
2016	+	}
2017	+}
2018	diff --git a/internal/app/verifier_router_test.go b/internal/app/verifier_router_test.go
2019	new file mode 100644
2020	index 0000000..03be748
2021	--- /dev/null
2022	+++ b/internal/app/verifier_router_test.go
2023	@@ -0,0 +1,251 @@
2024	+package app
2025	+
2026	+import (
2027	+	"context"
2028	+	"crypto/rand"
2029	+	"crypto/rsa"
2030	+	"encoding/base64"
2031	+	"encoding/json"
2032	+	"net/http"
2033	+	"net/http/httptest"
2034	+	"strings"
2035	+	"testing"
2036	+	"time"
2037	+
2038	+	"github.com/golang-jwt/jwt/v5"
2039	+
2040	+	"loginid-takehome/internal/config"
2041	+	"loginid-takehome/internal/dao"
2042	+	"loginid-takehome/internal/model"
2043	+)
2044	+
2045	+// fakeRepo is a minimal dao.Repository — only Profiles().Search is
2046	+// exercised by this file's tests; everything else panics, so a test
2047	+// that reaches an unexpected method fails loudly rather than silently
2048	+// zero-valuing (mirrors internal/api's own hand-written fakes,
2049	+// decisions/test-double-strategy.md).
2050	+type fakeRepo struct{ profiles fakeProfiles }
2051	+
2052	+func (f *fakeRepo) Profiles() dao.ProfileRepository       { return &f.profiles }
2053	+func (f *fakeRepo) Credentials() dao.CredentialRepository { panic("not used") }
2054	+func (f *fakeRepo) Methods() dao.AuthMethodRepository     { panic("not used") }
2055	+func (f *fakeRepo) CreateProfileWithCredential(ctx context.Context, p *model.UserProfile, c *model.UserCredential) (*model.UserProfile, *model.UserCredential, error) {
2056	+	panic("not used")
2057	+}
2058	+func (f *fakeRepo) DeleteExpired(ctx context.Context, class dao.RetentionClass, olderThan time.Time, maxRows int) (dao.SweepResult, error) {
2059	+	panic("not used")
2060	+}
2061	+func (f *fakeRepo) DeleteProfile(ctx context.Context, id string, externalRef *string) error {
2062	+	panic("not used")
2063	+}
2064	+func (f *fakeRepo) Close() error { return nil }
2065	+
2066	+type fakeProfiles struct{}
2067	+
2068	+func (fakeProfiles) Get(ctx context.Context, id string) (*model.UserProfile, error) {
2069	+	return &model.UserProfile{ID: id, Name: "Jane Doe"}, nil
2070	+}
2071	+func (fakeProfiles) Search(ctx context.Context, q dao.ProfileQuery) ([]model.UserProfile, int, error) {
2072	+	return []model.UserProfile{{ID: "p1", Name: "Jane Doe"}}, 1, nil
2073	+}
2074	+func (fakeProfiles) Create(ctx context.Context, p *model.UserProfile) (*model.UserProfile, error) {
2075	+	panic("not used")
2076	+}
2077	+func (fakeProfiles) Update(ctx context.Context, p *model.UserProfile) (*model.UserProfile, error) {
2078	+	panic("not used")
2079	+}
2080	+func (fakeProfiles) Upsert(ctx context.Context, p *model.UserProfile) (*model.UserProfile, error) {
2081	+	panic("not used")
2082	+}
2083	+func (fakeProfiles) Delete(ctx context.Context, id string) error { panic("not used") }
2084	+
2085	+const (
2086	+	testIssuer   = "https://auth.loginid-takehome.internal"
2087	+	testAudience = "loginid-api-service"
2088	+)
2089	+
2090	+func startFakeJWKS(t *testing.T, kid string, pub *rsa.PublicKey) *httptest.Server {
2091	+	t.Helper()
2092	+	body, err := json.Marshal(map[string]any{
2093	+		"keys": []map[string]string{{
2094	+			"kty": "RSA",
2095	+			"kid": kid,
2096	+			"alg": "RS256",
2097	+			"use": "sig",
2098	+			"n":   base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
2099	+			"e":   base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), // 65537
2100	+		}},
2101	+	})
2102	+	if err != nil {
2103	+		t.Fatalf("marshaling fake JWKS: %v", err)
2104	+	}
2105	+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2106	+		w.Header().Set("Content-Type", "application/json")
2107	+		_, _ = w.Write(body)
2108	+	}))
2109	+}
2110	+
2111	+func signTestToken(t *testing.T, key *rsa.PrivateKey, kid, scope string) string {
2112	+	t.Helper()
2113	+	c := jwt.MapClaims{
2114	+		"iss":   testIssuer,
2115	+		"aud":   testAudience,
2116	+		"sub":   "client-under-test",
2117	+		"scope": scope,
2118	+		"exp":   time.Now().Add(10 * time.Minute).Unix(),
2119	+		"iat":   time.Now().Unix(),
2120	+	}
2121	+	tok := jwt.NewWithClaims(jwt.SigningMethodRS256, c)
2122	+	tok.Header["kid"] = kid
2123	+	signed, err := tok.SignedString(key)
2124	+	if err != nil {
2125	+		t.Fatalf("signing test token: %v", err)
2126	+	}
2127	+	return signed
2128	+}
2129	+
2130	+func TestNewVerifierRouter_UnauthenticatedRequest_401(t *testing.T) {
2131	+	key, err := rsa.GenerateKey(rand.Reader, 2048)
2132	+	if err != nil {
2133	+		t.Fatalf("generating key: %v", err)
2134	+	}
2135	+	jwks := startFakeJWKS(t, "key-1", &key.PublicKey)
2136	+	defer jwks.Close()
2137	+
2138	+	cfg := config.Config{AuthJWTIssuer: testIssuer, AuthJWTAudience: testAudience, AuthJWKSURL: jwks.URL}
2139	+	router := NewVerifierRouter(cfg, &fakeRepo{})
2140	+
2141	+	req := httptest.NewRequest(http.MethodPost, "/profiles/search", strings.NewReader(`{"name":"a"}`))
2142	+	w := httptest.NewRecorder()
2143	+	router.ServeHTTP(w, req)
2144	+	if w.Code != http.StatusUnauthorized {
2145	+		t.Errorf("status = %d, want 401 with no bearer token", w.Code)
2146	+	}
2147	+}
2148	+
2149	+// TestNewVerifierRouter_AuthenticatedSearch_ReachesHandler proves the
2150	+// full stack — deadline middleware, JWT verification against a real
2151	+// JWKS-over-HTTP fetch, AuthContext population, and LT-39's handler —
2152	+// is wired together correctly end to end, not just each piece in
2153	+// isolation.
2154	+func TestNewVerifierRouter_AuthenticatedSearch_ReachesHandler(t *testing.T) {
2155	+	key, err := rsa.GenerateKey(rand.Reader, 2048)
2156	+	if err != nil {
2157	+		t.Fatalf("generating key: %v", err)
2158	+	}
2159	+	jwks := startFakeJWKS(t, "key-1", &key.PublicKey)
2160	+	defer jwks.Close()
2161	+
2162	+	cfg := config.Config{AuthJWTIssuer: testIssuer, AuthJWTAudience: testAudience, AuthJWKSURL: jwks.URL}
2163	+	router := NewVerifierRouter(cfg, &fakeRepo{})
2164	+	tok := signTestToken(t, key, "key-1", "profile:search")
2165	+
2166	+	req := httptest.NewRequest(http.MethodPost, "/profiles/search", strings.NewReader(`{"name":"jane"}`))
2167	+	req.Header.Set("Authorization", "Bearer "+tok)
2168	+	w := httptest.NewRecorder()
2169	+	router.ServeHTTP(w, req)
2170	+	if w.Code != http.StatusOK {
2171	+		t.Fatalf("status = %d, want 200, body=%s", w.Code, w.Body.String())
2172	+	}
2173	+}
2174	+
2175	+func TestNewVerifierRouter_HealthzUnaffectedByAuth(t *testing.T) {
2176	+	cfg := config.Config{AuthJWTIssuer: testIssuer, AuthJWTAudience: testAudience, AuthJWKSURL: "http://unused.invalid"}
2177	+	router := NewVerifierRouter(cfg, &fakeRepo{})
2178	+
2179	+	req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
2180	+	w := httptest.NewRecorder()
2181	+	router.ServeHTTP(w, req)
2182	+	if w.Code != http.StatusOK {
2183	+		t.Errorf("status = %d, want 200 — /healthz must stay unauthenticated per handoff-03-auth.md", w.Code)
2184	+	}
2185	+}
2186	+
2187	+// TestNewVerifierRouter_ReadOwn_AllowedAndDenied is Marcus Ilori's (02)
2188	+// ruling: the router-level test that was missing entirely for
2189	+// GET /profiles/{id} under read:own — both the allow and deny paths
2190	+// through the real StopgapAuthorizer, wired the way cmd/api-service
2191	+// actually wires it (via cfg.AuthzQASub/AuthzQAAllowedProfileID).
2192	+func TestNewVerifierRouter_ReadOwn_AllowedAndDenied(t *testing.T) {
2193	+	key, err := rsa.GenerateKey(rand.Reader, 2048)
2194	+	if err != nil {
2195	+		t.Fatalf("generating key: %v", err)
2196	+	}
2197	+	jwks := startFakeJWKS(t, "key-1", &key.PublicKey)
2198	+	defer jwks.Close()
2199	+
2200	+	cfg := config.Config{
2201	+		AuthJWTIssuer:           testIssuer,
2202	+		AuthJWTAudience:         testAudience,
2203	+		AuthJWKSURL:             jwks.URL,
2204	+		AuthzQASub:              "client-under-test", // matches signTestToken's hardcoded sub
2205	+		AuthzQAAllowedProfileID: "allowed-profile",
2206	+	}
2207	+	router := NewVerifierRouter(cfg, &fakeRepo{})
2208	+	tok := signTestToken(t, key, "key-1", "profile:read:own")
2209	+
2210	+	allowedReq := httptest.NewRequest(http.MethodGet, "/profiles/allowed-profile", nil)
2211	+	allowedReq.Header.Set("Authorization", "Bearer "+tok)
2212	+	wAllowed := httptest.NewRecorder()
2213	+	router.ServeHTTP(wAllowed, allowedReq)
2214	+	if wAllowed.Code != http.StatusOK {
2215	+		t.Errorf("allowed profile: status = %d, want 200, body=%s", wAllowed.Code, wAllowed.Body.String())
2216	+	}
2217	+
2218	+	deniedReq := httptest.NewRequest(http.MethodGet, "/profiles/some-other-profile", nil)
2219	+	deniedReq.Header.Set("Authorization", "Bearer "+tok)
2220	+	wDenied := httptest.NewRecorder()
2221	+	router.ServeHTTP(wDenied, deniedReq)
2222	+	if wDenied.Code != http.StatusForbidden {
2223	+		t.Errorf("unseeded profile: status = %d, want 403 (StopgapAuthorizer's safe-default deny), body=%s", wDenied.Code, wDenied.Body.String())
2224	+	}
2225	+}
2226	+
2227	+// TestNewVerifierRouter_NilRepo_ProtectedRoutesFailClosed503 is the PM's
2228	+// direct question: on a cluster where the database doesn't exist yet (no
2229	+// DB_DRIVER/DB_DSN configured), the verifying Deployment must still come
2230	+// up with /healthz green — cmd/api-service/main.go passes repo=nil
2231	+// rather than crash-looping — and the two protected routes must fail
2232	+// closed (503), not panic on a nil Repository. Auth still runs first: an
2233	+// unauthenticated request still gets 401, proven by the companion
2234	+// TestNewVerifierRouter_UnauthenticatedRequest_401-shaped assertion
2235	+// inline below.
2236	+func TestNewVerifierRouter_NilRepo_ProtectedRoutesFailClosed503(t *testing.T) {
2237	+	key, err := rsa.GenerateKey(rand.Reader, 2048)
2238	+	if err != nil {
2239	+		t.Fatalf("generating key: %v", err)
2240	+	}
2241	+	jwks := startFakeJWKS(t, "key-1", &key.PublicKey)
2242	+	defer jwks.Close()
2243	+
2244	+	cfg := config.Config{AuthJWTIssuer: testIssuer, AuthJWTAudience: testAudience, AuthJWKSURL: jwks.URL}
2245	+	router := NewVerifierRouter(cfg, nil) // no DAO repository — the no-database-yet case
2246	+	tok := signTestToken(t, key, "key-1", "profile:search")
2247	+
2248	+	// /healthz must still be green.
2249	+	healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
2250	+	wHealth := httptest.NewRecorder()
2251	+	router.ServeHTTP(wHealth, healthReq)
2252	+	if wHealth.Code != http.StatusOK {
2253	+		t.Errorf("/healthz status = %d, want 200 even with no DAO repository", wHealth.Code)
2254	+	}
2255	+
2256	+	// An authenticated request to a protected route must fail closed
2257	+	// (503), not panic.
2258	+	authedReq := httptest.NewRequest(http.MethodPost, "/profiles/search", strings.NewReader(`{"name":"a"}`))
2259	+	authedReq.Header.Set("Authorization", "Bearer "+tok)
2260	+	wAuthed := httptest.NewRecorder()
2261	+	router.ServeHTTP(wAuthed, authedReq)
2262	+	if wAuthed.Code != http.StatusServiceUnavailable {
2263	+		t.Errorf("authenticated request with no repository: status = %d, want 503", wAuthed.Code)
2264	+	}
2265	+
2266	+	// An UNauthenticated request must still get 401, not 503 — auth
2267	+	// still runs before the repository is ever consulted.
2268	+	unauthedReq := httptest.NewRequest(http.MethodPost, "/profiles/search", strings.NewReader(`{"name":"a"}`))
2269	+	wUnauthed := httptest.NewRecorder()
2270	+	router.ServeHTTP(wUnauthed, unauthedReq)
2271	+	if wUnauthed.Code != http.StatusUnauthorized {
2272	+		t.Errorf("unauthenticated request with no repository: status = %d, want 401 (auth must still run first)", wUnauthed.Code)
2273	+	}
2274	+}
2275	diff --git a/internal/config/config.go b/internal/config/config.go
2276	index bce9af9..37cf275 100644
2277	--- a/internal/config/config.go
2278	+++ b/internal/config/config.go
2279	@@ -29,6 +29,23 @@ type Config struct {
2280	 
2281	 	AuthJWTIssuer   string
2282	 	AuthJWTAudience string
2283	+	// AuthJWKSURL is the verifier's only path to public key material
2284	+	// (LT-40, handoff-03-auth.md v4): the issuer Deployment's in-cluster
2285	+	// ClusterIP Service JWKS endpoint, e.g.
2286	+	// "http://api-service-issuer.loginid-takehome.svc.cluster.local/.well-known/jwks.json".
2287	+	// Never a mounted key file — JWKS survives rotation without a config
2288	+	// change, since the verifier fetches by kid.
2289	+	AuthJWKSURL string
2290	+
2291	+	// AuthzQASub and AuthzQAAllowedProfileID seed the temporary,
2292	+	// default-deny StopgapAuthorizer (internal/api) for LT-40/LT-51's
2293	+	// joint live-URL review only — see StopgapAuthorizer's own doc
2294	+	// comment for why a permissive default was explicitly ruled out.
2295	+	// Both empty (the default) means the stand-in denies every
2296	+	// profile:read:own request, the safe default until LT-51 seeds a
2297	+	// real QA client credential and this is set to match it.
2298	+	AuthzQASub              string
2299	+	AuthzQAAllowedProfileID string
2300	 
2301	 	ConnectorJWTAudience  string
2302	 	ConnectorClientID     string
2303	@@ -87,6 +10
… truncated (20 KB cap)
Sep 13, 4:48 AM PDTOren Castellan
That's the whole diff (2313 lines, I've now seen it all). I have what I need for the review. I re-reviewed the full current state of PR #30 (`gh pr diff 30`, commit `03181f6`). **1. My two original findings — both correctly fixed.** `singleScope()` (jwtmiddleware.go) now dedupes by distinct `Scope` value in a `map[Scope]bool` rather than counting claim occurrences — `"profile:search profile:search"` now falls into the `len(found) == 1` branch and is accepted, only a genuinely distinct second scope trips the "more than one" rejection. Correct. `bearerToken()` now does `strings.EqualFold(h[:prefixLen], "Bearer ")`, with the `len(h) <= prefixLen` bounds check kept ahead of the slice — no panic on a short header, and `bearer`, `BEARER`, etc. now match per RFC 7235 §2.1. Correct. **2. JWKS staleness logic (`refresh()`, `reserveUnknownKidRetry`, the maxStaleness check) — one real issue.** The bounded fail-closed threshold itself is right: `maxStaleness = 3*ttl`, `refresh()` only returns `ErrJWKSStale` once `staleSince > maxStaleness` with the existing cache still in place, and returns nil (serve stale) below that bound — verified against `TestJWKSCache_StaleCacheSurvivesTransientFetchFailure` and `TestJWKSCache_FailsClosedPastMaxStaleness`, both consistent with the code path. The unknown-kid retry is correctly rate-limited to once per `jwksUnknownKidRefetchInterval`, and the lock-not-held-during-fetch fix is real (fetch happens outside `c.mu`, state mutation happens after re-acquiring it). The issue: **there is no backoff on the *normal* refresh path once the cache goes stale.** `KeyForKid`'s `needsRefresh` check is `keys == nil || now.Sub(fetchedAt) >= ttl`. On a failed fetch, `fetchedAt` is never advanced — only a successful fetch updates it. So once `ttl` has elapsed and the JWKS endpoint is down, `needsRefresh` is true on every single subsequent request, and every request calls `refresh()` → a real network fetch, one per request, for as long as the outage lasts (up to `maxStaleness`, then it fails closed — but by then it's already made one blocking HTTP call per authenticated request the whole time). The unknown-kid path got an explicit rate limit for exactly this reason ("Rate-limited so an unknown-kid flood can't be used to hammer the issuer's JWKS endpoint" — the code's own comment), but the ordinary TTL-refresh path has no equivalent throttle. Under load during an outage this is self-inflicted: every request now blocks on a real network round-trip (bounded by `defaultJWKSClientTimeout`/the caller's context deadline, so `deadlineBudget` = 5s per request) instead of failing fast against the cached state, and it keeps hammering the issuer's endpoint at full request volume for the entire outage window rather than backing off. This is worth a follow-up story or at minimum a rate limit on the ordinary refresh attempt itself (e.g., don't retry more than once per some short interval once a failure has been recorded), not a blocker for this PR's stated scope but a real gap in the same "don't hammer a struggling dependency" reasoning the PR already applied elsewhere. **3. New issues from this round's other changes.** Nothing else stood out as incorrect. `GrantLimiter`'s sweep is lock-held and bounded correctly (`sweepLocked`, `grantEntryTTL`), the exponential-backoff overflow guard (`backoff <= 0` catching a left-shift wraparound) is correct, `StopgapAuthorizer`'s default-deny-on-empty-map is verified by its own test, and `main.go`'s `ModeVerifier` nil-repo path plus `NewVerifierRouter`'s 503-not-panic behavior is exercised end-to-end by `TestNewVerifierRouter_NilRepo_ProtectedRoutesFailClosed503`, including confirming auth still runs before the repo is consulted (401 before 503). I did not find a new nullability, off-by-one, or error-swallowing defect in this round's diff. Recommend passing this to Renata with one line: item 2 above (JWKS refresh path has no backoff once stale, unlike the unknown-kid path) is a legitimate gap worth a named follow-up, not a hold on this PR.