{
"command": "pwd"
}/Users/warrenthompson/Source/LoginID/03-engineering-delivery
{
"file_path": "/Users/warrenthompson/Source/LoginID/CLAUDE.md"
}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 - **Before declaring anything handed off, merged-ready, or done, run `git status` and `git diff origin/main -- <your track>/`.** Four times in this project a finished artifact sat unpushed or on a dead branch while its author reported it complete (2026-09-12/13). The author is the worst-placed person to notice, because they can see the work; the reviewer looking at `origin/main` sees the truth. Also check `gh pr view <n> --json state` before pushing to a story branch — once the PM merges, that branch is dead and pushes to it go nowhere.
69 - Edit `CLAUDE.md` files at turn boundaries only (prompt-cache stability). Milestone `git` commits at the end of each phase; no mid-task commits.
70
71 ## Status
72
73 Planning (Phases 0–4) closed 2026-09-12: five track plans, 18 hires, three cross-track consistency passes (`decisions/cross-track-consistency.md`), and a Jira backlog (`LT-1`…`LT-52`). Implementation began the same evening as an agile loop (`LOG.md` from "Implementation go" onward). As of 2026-09-13 06:00 PDT: **Done end to end** (deployed to the public URL and PO-reviewed) — LT-34 walking skeleton, LT-32 issuer split-Deployment, LT-52 in-namespace Postgres + migrations. **Live:** `https://loginid-takehome.uplifttech.org/healthz` (build SHA == `main`, `db: ok`); `/profiles/search` and `/profiles/{id}` deployed behind RS256 JWT verification and failing closed; `/auth/token` a 501 placeholder until LT-51. **In Test** awaiting one joint live review with a real token: LT-36/37 (Postgres/CockroachDB and SQLite DAOs), LT-38 (conformance suite), LT-39 (REST search/read), LT-40 (auth middleware). **In dev:** LT-51 (client-credentials issuance; PRs #34 + #36), LT-41 (idp-connector, Q3). PRs #1–#37 merged. Teardown runs only on Warren's instruction (`04-infra-devops/teardown.md`).
74
75 <!-- profile-gen:start slug=dana-whitfield -->
76 @profiles/dana-whitfield/dana-whitfield.md
77 <!-- profile-gen:end slug=dana-whitfield -->
78 {
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/CLAUDE.md"
}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
{
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/PLAN.md"
}1 # Track 03 (Engineering & Delivery) — Phase 1 Plan 2 3 ## Context 4 5 Team-lead has asked every track lead to plan in plan mode before any hiring or code. This 6 track (Renata Cole) owns the actual Go implementation of all three assignment questions — 7 the DAO, the REST API, and the IDP connector — so its plan needs to name concrete, 8 Jira-shaped deliverables, propose a 4-hire roster with real personality variance (per 9 `CASTING.md`), decide which decisions in this track deserve a debate pattern versus which are 10 waste, state the dependency order this track already committed to in 11 `../02-ai-security-architecture/planning-approach.md`, and produce the "Service boundaries" 12 section of root `PLANNING.md` that 04 (Theo) is blocked on reading. Nothing here is new 13 strategy — it operationalizes what's already agreed in `planning-approach.md` and the root 14 `CLAUDE.md`'s org section. No code is written in this phase. 15 16 ## 1. Deliverables & tasks (one Jira Story each; Epic = this track) 17 18 *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
{
"file_path": "/Users/warrenthompson/Source/LoginID/03-engineering-delivery/profiles/oren-castellan/oren-castellan.md"
}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  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
{
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees 2>/dev/null; gh pr diff 59 --repo wtvamp/loginid-takehome > /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr59.diff 2>&1; wc -l /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr59.diff"
}699 /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr59.diff
{
"file_path": "/private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/pr59.diff"
}1 diff --git a/cmd/api-service/main.go b/cmd/api-service/main.go
2 index 5b27899..db1c6b6 100644
3 --- a/cmd/api-service/main.go
4 +++ b/cmd/api-service/main.go
5 @@ -158,7 +158,8 @@ func runSweepMode(cfg config.Config) {
6 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
7 defer stop()
8
9 - results := sweep.Run(ctx, repo, time.Now(), sweep.StdoutMetricEmitter{})
10 + metrics := sweep.NewPrometheusMetricEmitter()
11 + results := sweep.Run(ctx, repo, time.Now(), metrics)
12
13 failed := false
14 for _, r := range results {
15 @@ -169,6 +170,20 @@ func runSweepMode(cfg config.Config) {
16 }
17 log.Printf("api-service: sweep: class %q drained, examined %d row(s), deleted %d", r.Class, r.RowsExamined, r.RowsDeleted)
18 }
19 +
20 + // Rendered once, after every class has been attempted, so the real
21 + // Prometheus-formatted metric lines land together in the pod's
22 + // stdout rather than interleaved with the per-class log lines above
23 + // — LT-49's agreed mechanism with Theo Bergman (04): stdout via
24 + // real client_golang Gauge objects, not a Pushgateway push (none
25 + // runs on this cluster; see PrometheusMetricEmitter's own doc
26 + // comment). A failure to render is logged but never turns an
27 + // otherwise-successful sweep into a failed Job — the metrics are an
28 + // observability nicety this run's own success doesn't depend on.
29 + if err := metrics.Render(os.Stdout); err != nil {
30 + log.Printf("api-service: sweep: writing metrics: %v", err)
31 + }
32 +
33 if failed {
34 os.Exit(1)
35 }
36 diff --git a/go.mod b/go.mod
37 index c456c7b..825d1b6 100644
38 --- a/go.mod
39 +++ b/go.mod
40 @@ -7,12 +7,16 @@ require (
41 github.com/google/uuid v1.6.0
42 github.com/jackc/pgx/v5 v5.11.0
43 github.com/pressly/goose/v3 v3.28.0
44 + github.com/prometheus/client_golang v1.24.1
45 + github.com/prometheus/common v0.70.1
46 golang.org/x/crypto v0.57.0
47 gopkg.in/yaml.v3 v3.0.1
48 modernc.org/sqlite v1.58.0
49 )
50
51 require (
52 + github.com/beorn7/perks v1.0.1 // indirect
53 + github.com/cespare/xxhash/v2 v2.3.0 // indirect
54 github.com/dustin/go-humanize v1.0.1 // indirect
55 github.com/jackc/pgpassfile v1.0.0 // indirect
56 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
57 @@ -20,7 +24,10 @@ require (
58 github.com/kr/text v0.2.0 // indirect
59 github.com/mattn/go-isatty v0.0.24 // indirect
60 github.com/mfridman/interpolate v0.0.2 // indirect
61 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
62 github.com/ncruces/go-strftime v1.0.0 // indirect
63 + github.com/prometheus/client_model v0.6.2 // indirect
64 + github.com/prometheus/procfs v0.22.0 // indirect
65 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
66 github.com/rogpeppe/go-internal v1.16.0 // indirect
67 github.com/sethvargo/go-retry v0.4.0 // indirect
68 @@ -28,6 +35,7 @@ require (
69 golang.org/x/sync v0.23.0 // indirect
70 golang.org/x/sys v0.48.0 // indirect
71 golang.org/x/text v0.42.0 // indirect
72 + google.golang.org/protobuf v1.36.12 // indirect
73 modernc.org/libc v1.75.6 // indirect
74 modernc.org/mathutil v1.7.1 // indirect
75 modernc.org/memory v1.12.1 // indirect
76 diff --git a/go.sum b/go.sum
77 index 91f7a49..9518280 100644
78 --- a/go.sum
79 +++ b/go.sum
80 @@ -1,9 +1,15 @@
81 +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
82 +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
83 +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
84 +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
85 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
86 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
87 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
88 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
89 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
90 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
91 +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
92 +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
93 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
94 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
95 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
96 @@ -26,11 +32,21 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
97 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
98 github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
99 github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
100 +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
101 +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
102 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
103 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
104 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
105 github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
106 github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
107 +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
108 +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
109 +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
110 +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
111 +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
112 +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
113 +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
114 +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
115 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
116 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
117 github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
118 @@ -42,8 +58,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
119 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
120 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
121 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
122 +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
123 +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
124 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
125 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
126 +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
127 +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
128 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
129 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
130 golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
131 @@ -58,6 +78,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
132 golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
133 golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
134 golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
135 +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
136 +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
137 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
138 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
139 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
140 diff --git a/internal/api/neverlog_test.go b/internal/api/neverlog_test.go
141 new file mode 100644
142 index 0000000..72f71f4
143 --- /dev/null
144 +++ b/internal/api/neverlog_test.go
145 @@ -0,0 +1,144 @@
146 +package api
147 +
148 +import (
149 + "bytes"
150 + "context"
151 + "fmt"
152 + "log"
153 + "net/http"
154 + "net/http/httptest"
155 + "net/url"
156 + "strings"
157 + "testing"
158 +
159 + "golang.org/x/crypto/argon2"
160 +
161 + "loginid-takehome/internal/dao"
162 + "loginid-takehome/internal/model"
163 +)
164 +
165 +// captureLog redirects the standard library's package-level logger
166 +// (what every real log.Printf call site in this package writes
167 +// through) to a buffer for the duration of the test, restoring the
168 +// previous output on cleanup. This is LT-49's application-layer
169 +// complement to 04's static CI lint check (handoff-04-secrets.md's
170 +// never-log list): a lint rule can catch an obviously-named variable
171 +// passed straight to a log call, but can't catch a value that reaches a
172 +// log line through an intermediate variable, a struct field, or a
173 +// derived string the rule's own pattern doesn't match — this captures
174 +// and inspects the ACTUAL bytes written during a real request, driven
175 +// through the real handler/audit-logging code path, not a static scan
176 +// of the source text.
177 +func captureLog(t *testing.T) *bytes.Buffer {
178 + t.Helper()
179 + var buf bytes.Buffer
180 + prev := log.Writer()
181 + log.SetOutput(&buf)
182 + t.Cleanup(func() { log.SetOutput(prev) })
183 + return &buf
184 +}
185 +
186 +// TestNeverLog_TokenHandler_ClientSecretNeverLogged drives the real
187 +// opportunistic-rehash-persist-failure path (the log call site closest
188 +// to ever touching a secret value, tokenhandler.go's own
189 +// "storing failed" branch) with a distinctive, never-otherwise-used
190 +// client secret, and asserts the raw secret string never appears
191 +// anywhere in the captured log output — item 6 of
192 +// handoff-04-secrets.md's never-log list (API caller client_secrets, in
193 +// plaintext or hashed).
194 +func TestNeverLog_TokenHandler_ClientSecretNeverLogged(t *testing.T) {
195 + const secret = "this fixture client secret must never appear in logs"
196 + salt := []byte("0123456789abcdef")
197 + const oldTime = 2 // deliberately drifted from this package's current argon2Time
198 + oldHash := argon2.IDKey([]byte(secret), salt, oldTime, argon2Memory, argon2Threads, argon2KeyLen)
199 + driftedEncoded := encodePHC(argon2Memory, oldTime, argon2Threads, salt, oldHash)
200 +
201 + store := &fakeClientStore{
202 + records: map[string]ClientRecord{
203 + "drifted-client": {ClientID: "drifted-client", Name: "Drifted", ClientSecretHash: driftedEncoded, GrantedScope: Scope("profile:search"), Audience: "aud", Active: true},
204 + },
205 + // Forces the rehash PERSIST to fail, exercising the log line at
206 + // tokenhandler.go's "storing failed" branch — the one call site
207 + // closest to a temptation to log the secret it just verified.
208 + updateHashErr: fmt.Errorf("simulated issuer database write failure"),
209 + }
210 + limiter := &fakeGrantLimiter{allow: true}
211 + h := NewTokenHandler(store, limiter, testSigningKey(t), "https://issuer.test")
212 +
213 + buf := captureLog(t)
214 +
215 + form := url.Values{"grant_type": {"client_credentials"}}
216 + resp := postToken(t, h, form, "drifted-client", secret, true)
217 + if resp.StatusCode != http.StatusOK {
218 + t.Fatalf("status = %d, want 200 (grant already earned before the rehash attempt)", resp.StatusCode)
219 + }
220 +
221 + if strings.Contains(buf.String(), secret) {
222 + t.Errorf("captured log output contains the raw client secret — never-log-list item 6 violated:\n%s", buf.String())
223 + }
224 + if buf.Len() == 0 {
225 + t.Fatalf("captured log output is empty — the rehash-failure log line never fired, this test isn't exercising the intended path")
226 + }
227 +}
228 +
229 +// TestNeverLog_SearchAndGet_PIINeverLogged drives the real
230 +// search-then-get flow (through the REAL StdoutAuditLogger, not a test
231 +// fake) with seeded, distinctive PII values, and asserts none of them
232 +// ever appear in captured log output — item 3 of the never-log list
233 +// (PII field values; only the fact of access — sub, scope, opaque
234 +// record id, timestamp — may be logged).
235 +func TestNeverLog_SearchAndGet_PIINeverLogged(t *testing.T) {
236 + const seededName = "Zzyxwvut NeverLogThisName"
237 + const seededPhone = "+15559998888"
238 + const seededStreet = "742 NeverLog Evergreen Terrace"
239 +
240 + profile := model.UserProfile{
241 + ID: "11111111-1111-1111-1111-111111111111", Name: seededName, Phone: strp(seededPhone),
242 + StreetAddress: strp(seededStreet),
243 + }
244 +
245 + repo := &fakeRepository{}
246 + repo.profiles.searchFn = func(_ context.Context, _ dao.ProfileQuery) ([]model.UserProfile, int, error) {
247 + return []model.UserProfile{profile}, 1, nil
248 + }
249 + repo.profiles.getFn = func(_ context.Context, id string) (*model.UserProfile, error) {
250 + return &profile, nil
251 + }
252 +
253 + deps := Deps{
254 + Repo: repo,
255 + Authz: &fakeAuthorizer{allow: true},
256 + RateLimiter: &fakeRateLimiter{allow: true},
257 + TouchCounter: &fakeTouchCounter{reserveOK: true},
258 + AuditLog: StdoutAuditLogger{}, // the real implementation, not a test fake
259 + }
260 +
261 + buf := captureLog(t)
262 +
263 + searchReq := httptest.NewRequest(http.MethodPost, "/profiles/search", strings.NewReader(`{"name":"Zzyxwvut"}`))
264 + searchReq = searchReq.WithContext(withScope("client1", ScopeSearch))
265 + searchW := httptest.NewRecorder()
266 + NewSearchHandler(deps)(searchW, searchReq)
267 + if searchW.Code != http.StatusOK {
268 + t.Fatalf("search status = %d, want 200, body=%s", searchW.Code, searchW.Body.String())
269 + }
270 +
271 + getReq := httptest.NewRequest(http.MethodGet, "/profiles/"+profile.ID+"?reason_code=support_ticket", nil)
272 + getReq.SetPathValue("id", profile.ID)
273 + getReq = getReq.WithContext(withScope("client1", ScopeReadAny))
274 + getW := httptest.NewRecorder()
275 + NewGetProfileHandler(deps)(getW, getReq)
276 + if getW.Code != http.StatusOK {
277 + t.Fatalf("get status = %d, want 200, body=%s", getW.Code, getW.Body.String())
278 + }
279 +
280 + out := buf.String()
281 + for _, seeded := range []string{seededName, seededPhone, seededStreet} {
282 + if strings.Contains(out, seeded) {
283 + t.Errorf("captured log output contains seeded PII value %q — never-log-list item 3 violated:\n%s", seeded, out)
284 + }
285 + }
286 + if out == "" {
287 + t.Fatalf("captured log output is empty — the audit log never fired, this test isn't exercising the intended path")
288 + }
289 +}
290 diff --git a/internal/connector/neverlog_test.go b/internal/connector/neverlog_test.go
291 new file mode 100644
292 index 0000000..736571f
293 --- /dev/null
294 +++ b/internal/connector/neverlog_test.go
295 @@ -0,0 +1,121 @@
296 +package connector
297 +
298 +import (
299 + "bytes"
300 + "context"
301 + "log"
302 + "net/http"
303 + "net/http/httptest"
304 + "strings"
305 + "testing"
306 + "time"
307 +
308 + "loginid-takehome/internal/api"
309 +)
310 +
311 +// captureLog redirects the standard library's package-level logger to a
312 +// buffer for the duration of the test — LT-49's application-layer
313 +// complement to 04's static CI lint check (handoff-04-secrets.md's
314 +// never-log list): this inspects the actual bytes written during a
315 +// real request through the real handler/audit-logging path, catching
316 +// what a static grep-style rule can miss.
317 +func captureLog(t *testing.T) *bytes.Buffer {
318 + t.Helper()
319 + var buf bytes.Buffer
320 + prev := log.Writer()
321 + log.SetOutput(&buf)
322 + t.Cleanup(func() { log.SetOutput(prev) })
323 + return &buf
324 +}
325 +
326 +// TestNeverLog_AuthHandler_VendorCredentialNeverLogged drives a real
327 +// /auth request — success AND failure branches — with a distinctive
328 +// vendor username/password, and asserts neither ever appears in
329 +// captured log output. Item 1 (end-user vendor password) and the
330 +// Authorization-header half of item 2 of handoff-04-secrets.md's
331 +// never-log list.
332 +func TestNeverLog_AuthHandler_VendorCredentialNeverLogged(t *testing.T) {
333 + const username = "this fixture username must never appear in logs"
334 + const password = "this fixture password must never appear in logs"
335 +
336 + limiter := api.NewInProcessGrantLimiter(time.Second, time.Minute, 5, nil)
337 +
338 + cases := []struct {
339 + name string
340 + authFn func(ctx context.Context, u, p string) (string, error)
341 + }{
342 + {name: "success", authFn: func(_ context.Context, u, p string) (string, error) {
343 + return "vendor-token-abc", nil
344 + }},
345 + {name: "vendor rejects", authFn: func(_ context.Context, u, p string) (string, error) {
346 + return "", ErrVendorAuthFailed
347 + }},
348 + }
349 + for _, c := range cases {
350 + t.Run(c.name, func(t *testing.T) {
351 + vendor := &fakeVendorClient{authFn: c.authFn}
352 + h := NewAuthHandler(Deps{Vendor: vendor, Limiter: limiter, AuditLog: api.StdoutAuditLogger{}})
353 +
354 + buf := captureLog(t)
355 +
356 + body := strings.NewReader(`{"username":"` + username + `","password":"` + password + `"}`)
357 + req := withAuthContext("caller-1")(httptest.NewRequest(http.MethodPost, "/auth", body))
358 + w := httptest.NewRecorder()
359 + h.ServeHTTP(w, req)
360 +
361 + out := buf.String()
362 + if strings.Contains(out, password) {
363 + t.Errorf("captured log output contains the raw vendor password — never-log-list item 1 violated:\n%s", out)
364 + }
365 + if strings.Contains(out, username) {
366 + t.Errorf("captured log output contains the raw vendor username — never-log-list item 11 violated:\n%s", out)
367 + }
368 + })
369 + }
370 +}
371 +
372 +// TestNeverLog_IdentityHandler_VendorAccessTokenNeverLogged drives a
373 +// real /identity request through all three of its outcomes with a
374 +// distinctive vendor access token, and asserts it never appears in
375 +// captured log output — item 2 of the never-log list (vendor
376 +// access_token, and any Authorization header value in full).
377 +func TestNeverLog_IdentityHandler_VendorAccessTokenNeverLogged(t *testing.T) {
378 + const vendorToken = "this fixture vendor token must never appear in logs"
379 +
380 + limiter := api.NewInProcessGrantLimiter(time.Second, time.Minute, 5, nil)
381 +
382 + cases := []struct {
383 + name string
384 + identityFn func(ctx context.Context, accessToken, phone, name string) (Identity, error)
385 + sendNoHeader bool
386 + }{
387 + {name: "success", identityFn: func(_ context.Context, accessToken, _, _ string) (Identity, error) {
388 + return Identity{Name: "Jane Doe"}, nil
389 + }},
390 + {name: "vendor rejects toke
… truncated (20 KB cap){
"command": "cd /Users/warrenthompson/Source/LoginID-worktrees 2>/dev/null || true\nmkdir -p /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/repo\ncd /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/repo\ngit clone --quiet «email»:wtvamp/loginid-takehome.git . 2>&1 | tail -5 || gh repo clone wtvamp/loginid-takehome . 2>&1 | tail -20\ngit fetch origin pull/59/head:pr59 2>&1 | tail -5\ngit checkout pr59 2>&1 | tail -5\ngrep -rn \"t.Parallel()\" internal/api internal/connector internal/sweep 2>/dev/null\necho ---\nls internal/api | grep -i test\necho ---\nls internal/connector | grep -i test"
}From github.com:wtvamp/loginid-takehome * [new ref] refs/pull/59/head -> pr59 Switched to branch 'pr59' internal/api/clientstore_pg_test.go:62:// Do not add t.Parallel() to this file's tests without first addressing --- clientstore_pg_test.go clientstore_test.go cursor_test.go fakes_test.go grantlimiter_test.go handlers_test.go issuer_verifier_integration_test.go jwks_test.go jwksendpoint_test.go jwtmiddleware_test.go masking_test.go neverlog_test.go ratelimit_test.go signingkey_test.go stopgap_authorizer_test.go tokenhandler_test.go touchcounter_test.go --- handlers_test.go httpvendorclient_test.go identitylimiter_test.go neverlog_test.go stubvendorclient_test.go
{
"command": "cd /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/repo\ngrep -n \"log.SetOutput\\|log.Writer\\|masking\\|StdoutAuditLogger\" internal/api/masking_test.go internal/api/handlers_test.go internal/api/tokenhandler_test.go 2>/dev/null | head -60"
}(Bash completed with no output)
{
"command": "cd /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/repo\necho \"--- masking_test.go ---\"\nsed -n '1,80p' internal/api/masking_test.go\necho \"--- StdoutAuditLogger def ---\"\ngrep -rn \"StdoutAuditLogger\" internal/api/*.go | grep -v _test"
}--- masking_test.go ---
package api
import "testing"
func TestMaskPhone(t *testing.T) {
cases := []struct {
in *string
want *string
}{
{nil, nil},
{strp("+15551234567"), strp("***-***-4567")},
{strp("123"), strp("***")},
}
for _, tc := range cases {
got := maskPhone(tc.in)
switch {
case tc.want == nil && got != nil:
t.Errorf("maskPhone(%v) = %v, want nil", tc.in, *got)
case tc.want != nil && got == nil:
t.Errorf("maskPhone(%v) = nil, want %v", *tc.in, *tc.want)
case tc.want != nil && got != nil && *got != *tc.want:
t.Errorf("maskPhone(%v) = %v, want %v", *tc.in, *got, *tc.want)
}
}
}
--- StdoutAuditLogger def ---
internal/api/audit.go:67:// StdoutAuditLogger writes one JSON line per event to the process's
internal/api/audit.go:73:type StdoutAuditLogger struct{}
internal/api/audit.go:75:func (StdoutAuditLogger) Log(_ context.Context, event AuditEvent) {{
"command": "cd /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/repo\nsed -n '1,100p' internal/api/audit.go"
}package api
import (
"context"
"encoding/json"
"log"
"time"
)
// AuditEventKind names a policy decision for the audit trail —
// handoff-03-auth.md v3's audit-log field list requires "policy decision
// (allow/deny, and if deny, whether it was a scope failure or an
// authorize() policy denial — these are distinct signals for detection)"
// on every search/retrieve call. The client-visible response body is
// deliberately identical for AuditScopeFailure and AuditPolicyDenial
// (decisions/error-semantics.md F7) — this is the ONLY place that
// distinction is allowed to exist.
type AuditEventKind string
const (
AuditAuthnFailure AuditEventKind = "authn_failure"
AuditScopeFailure AuditEventKind = "scope_failure"
AuditPolicyDenial AuditEventKind = "authorize_denial"
AuditRateLimited AuditEventKind = "rate_limited"
AuditCapExceeded AuditEventKind = "cumulative_cap_exceeded"
AuditRequestFailed AuditEventKind = "request_failed" // a DAO/internal error, not a policy decision
AuditSuccess AuditEventKind = "success"
// AuditJWKSUnavailable is LT-40's distinct signal for the JWKS
// fail-closed case (Tomasz Wrede, 02 cold review, PM ruling): the
// cached key set exceeded its max-staleness bound with no successful
// refresh, and the verifier refused every token rather than trust
// keys that old — a token-infrastructure degradation, not an
// ordinary bad-credential attempt, and worth distinguishing from
// AuditAuthnFailure for exactly that reason.
AuditJWKSUnavailable AuditEventKind = "jwks_unavailable"
)
// AuditEvent carries exactly the fields handoff-03-auth.md v3's audit-log
// section names, and only those — RecordIDs are opaque ids, never PII
// field values; ReasonCode is empty unless the request was
// ScopeReadAny. Never add a field here without checking
// handoff-04-secrets.md's never-log list first (see AuditEvent's own
// logger implementations for the enforcement site).
type AuditEvent struct {
Sub string
Scope Scope
Kind AuditEventKind
RecordIDs []string
ReasonCode ReasonCode
Timestamp time.Time
}
// AuditLogger is the seam this story wires (Ingrid Solano's PR #26
// review, ruled by the PM as an unmet acceptance criterion, not a
// follow-up story): handoff-03-auth.md v3 requires reason_code and the
// scope/authorize() distinction to be "logged with the audit record" —
// this interface is what every handler decision point calls, regardless
// of which real sink backs it. A structured-stdout implementation is
// sufficient for this story; 04's retention/RBAC-separated log pipeline
// is a separate, infrastructure-side concern this interface doesn't
// depend on.
type AuditLogger interface {
Log(ctx context.Context, event AuditEvent)
}
// StdoutAuditLogger writes one JSON line per event to the process's
// standard logger. Real production wiring (a shipped log pipeline with
// the RBAC separation handoff-04-secrets.md requires between Secret
// access and audit-log access) is 04's infrastructure work; this
// satisfies "events are actually emitted somewhere real" for this story
// without waiting on that pipeline to exist.
type StdoutAuditLogger struct{}
func (StdoutAuditLogger) Log(_ context.Context, event AuditEvent) {
if event.Timestamp.IsZero() {
event.Timestamp = time.Now().UTC()
}
b, err := json.Marshal(event)
if err != nil {
log.Printf("api: audit event marshal error: %v", err)
return
}
log.Printf("audit: %s", b)
}{
"command": "cd /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/repo\ngrep -n \"log\\.\\|log\\.Printf\" internal/api/handlers.go 2>/dev/null | head -30\necho ---\nls internal/api | grep -i handler\necho --- tokenhandler.go rehash log line ---\ngrep -n \"log.Printf\\|storing failed\\|UpdateSecretHash\" internal/api/tokenhandler.go"
}---
handlers.go
handlers_test.go
tokenhandler.go
tokenhandler_test.go
--- tokenhandler.go rehash log line ---
189: // still-drifted client: UpdateSecretHash's WHERE clause targets
199: log.Printf("api: opportunistic rehash for client_id=%s: hashing failed: %v", clientID, err)
200: } else if err := store.UpdateSecretHash(ctx, clientID, newHash); err != nil {
201: log.Printf("api: opportunistic rehash for client_id=%s: storing failed: %v", clientID, err){
"command": "cd /private/tmp/claude-501/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/scratchpad/repo\ngo build ./... 2>&1 | head -50"
}(Bash completed with no output)