Oren Castellan

Hire (track 03) · session 3 of 26 · model claude-sonnet-5 · Sep 12, 2026 9:01 PM PDT – Sep 12, 2026 9:03 PM PDT
Sep 12, 9:01 PM PDTLead
Review GitHub PR #16 (wtvamp/loginid-takehome, branch feature/LT-38, title "LT-38: cross-backend behavioral conformance suite") for error semantics, nullability, and off-by-one issues. Context: this PR adds internal/dao/conformance — a suite of behavioral assertions run against all three real DAO backends (Postgres, CockroachDB, SQLite) in one test binary. It's the final piece of the 03-engineering-delivery DAO work (LT-36/LT-37 already merged). The PR's own description on GitHub has the full context and catch list from the review rounds. Use `gh pr view 16 --repo wtvamp/loginid-takehome` and `gh pr diff 16 --repo wtvamp/loginid-takehome` to read the actual PR description and diff. Focus your review on internal/dao/conformance/{conformance_test.go,helpers_test.go,testutil_test.go} — that's the entire diff. Report back in plain prose (not a tool call) either: - A list of concrete findings (file/line, what's wrong, why it matters), most important first, or - "No findings" if the suite is correct, if that's genuinely your assessment after reading it. Be honest — don't manufacture findings if there aren't any real ones.
Sep 12, 9:01 PM 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 12, 9:02 PM PDTOren Castellan
Bash gh pr view 16 --repo wtvamp/loginid-takehome
input
{
  "command": "gh pr view 16 --repo wtvamp/loginid-takehome"
}
result
title:	LT-38: cross-backend behavioral conformance suite
state:	OPEN
author:	wtvamp (Warren Thompson)
labels:	
assignees:	
reviewers:	
projects:	
milestone:	
number:	16
url:	https://github.com/wtvamp/loginid-takehome/pull/16
additions:	858
deletions:	0
auto-merge:	disabled
--
## Summary

Adds `internal/dao/conformance` — one suite of behavioral assertions, written once against the `dao.Repository` interface, run against all three real backend implementations (Postgres 16, CockroachDB v23.2.0, SQLite via `modernc.org/sqlite`) in a single test binary. Implements `05-data-ops/multi-db-strategy.md` §7 ("The conformance suite — a requirement, not a suggestion"): signature conformance (LT-35, already merged, `internal/dao/conformance_test.go`) proves the three packages compile against the same interfaces; this proves they *behave* alike given the same inputs against a live database. §7 is explicit that the first cannot substitute for the second — two implementations can satisfy the same interface and diverge in what they actually return, which is exactly what this phase found three separate times.

This PR also carries the catch list from the LT-36/LT-37 implementation and review rounds — the defects this conformance suite (or direct testing against real backends) actually found, and what closed each one, since the reasoning behind the suite is as much the deliverable here as the suite itself.

## The catch list

Each item found a genuine cross-backend divergence invisible from reading either implementation in isolation — the exact failure mode §7 says the suite exists to catch.

- **Identifier validation (Amendment A4, §3a/§4/§7)** — `Get` with a malformed id diverged by backend: Postgres/CockroachDB raise `22P02` before any lookup (`id` is `UUID`-typed), SQLite returns `ErrNotFound` (`id` is `TEXT`). Ruled: malformed *or non-canonical* ids return `ErrInvalidArgument`, validated once above the backends against the canonical form only. Went through an objection round (Yusuf Karadag, five objections, all accepted) after the first validator moved the divergence rather than closing it — `uuid.Parse` admits textual forms Postgres normalizes and SQLite byte-compares and misses.
- **COLLATE "C" invalid on CockroachDB (Amendment A5, §5/§6.3/§6.9)** — the shared `postgres/` migration directory's `COLLATE "C"` clause is invalid syntax on CockroachDB (`42601`); CockroachDB's default `TEXT` comparison is already byte-order and needs no clause. Blocked the CockroachDB conformance run outright until the migration directories were split three ways, one per driver. Verified directly against live CockroachDB v23.2.0, including the empirical check that its uncollated default already gives the required ordering.
- **SQLite `foreign_keys` pragma is per-connection (Amendment A3, §6.2/§7)** — `PRAGMA foreign_keys = ON` run once after opening only covered the connection that ran it; the cascade guarantee and `ErrInvalidMethod` FK enforcement silently stopped applying the moment the pool opened a second connection. Fixed via the `_foreign_keys=on` DSN param (applies per-connection by construction), verified by temporarily reverting it and watching `TestIntegration_ProfileDeleteCascadesToCredentials` fail.
- **`requires_secret`/`is_active` enforcement (§6.10, §6.12)** — neither can be a DDL `CHECK` (one needs `is_active` read from `auth_method`, the other needs `requires_secret` read cross-table); both were missing DAO-layer validation before this round. Now enforced in both backends' `CreateProfileWithCredential`/credential-create paths, each with a conformance assertion.
- **`Country` write-side normalization (§6.5)** — upper-casing the query without upper-casing the stored value made a stored `us` invisible to a search for `US`. Fixed at write time in both backends' `NormalizeProfileForWrite`.
- **`DeleteExpired`/`DeleteProfile` TOCTOU (review round, Oren Castellan)** — the original select-then-loop-delete (and select-then-delete) pattern could delete a row that had stopped matching its retention predicate between selection and deletion. Postgres/CockroachDB: collapsed to one CTE-based statement where the `DELETE` re-checks the candidate `SELECT`'s predicate (CockroachDB additionally requires `RETURNING` on every write CTE, or it rejects the statement as returning no columns). SQLite: re-adds the predicate to the per-row `DELETE` directly, since SQLite has no writable-CTE support (confirmed by testing DML inside `WITH` directly: `SQL logic error: near DELETE: syntax error`) — same fix intent, backend-appropriate mechanism.
- **No backoff on CockroachDB retry (review round, Nolan Reyes)** — `retryLoop` retried a `40001` serialization failure in a tight spin loop; added jittered backoff between attempts, with a test confirming a cancelled context aborts the wait rather than blocking for its full duration.
- **Nil-vs-empty-slice JSON serialization (review round, Oren Castellan)** — `Search`/`ListByUserID`/`List` returned a nil slice on zero results, serializing to `null` instead of `[]`; fixed in both backends.

## What's in this diff specifically

Only `internal/dao/conformance/{conformance_test.go, helpers_test.go, testutil_test.go}` — everything else the catch list above touches (the DAO code itself, the split migrations, the id-validation/normalization fixes) is already on `main` via PR #6 (LT-36) and PR #8 (LT-37), including their review-round fixes. This branch's own commit history had replayed pre-rebase copies of those two branches and produced spurious whole-file conflicts against their final merged versions on `main`; rather than force a rebase through that, this PR resets cleanly onto `main` and re-adds the one piece that doesn't already exist there.

The suite covers, per backend: `errors.Is` sentinel parity for every `dao.Err*` (including the id-validation and duplicate-username/invalid-method/invalid-query/invalid-argument/invalid-credential cases), `requires_secret`/`is_active` enforcement, cascade-delete and no-cascade-on-credential-delete semantics, `DeleteExpired` (invalid class, `maxRows` bounds, `OldestSurvivingAt` nil-vs-non-nil, `Drained`), exactly-one `deletion_log` row per delete, an already-cancelled-context deletes-nothing check, and `TestConformance_CockroachDB_RealSerializationRetry` — which forces a genuine `40001` against live CockroachDB rather than a mocked error, so the retry path is proven end-to-end and not just against `retry_test.go`'s `fakeErr()`.

## Verification

- `go build ./...` / `go vet ./...` clean.
- `go test ./...` green against real Postgres 16 (`postgres:16-alpine`), real CockroachDB v23.2.0 (`cockroachdb/cockroach:v23.2.0`), and `modernc.org/sqlite` — `internal/dao/postgres`, `internal/dao/sqlite`, `internal/dao/conformance` all pass.
- Note: `internal/dao`'s `TestContractChecksumUpToDate` (LT-35's suite, unrelated to this package) currently fails on `main` at `fd2505f` — the contract A4 amendment changed `05-data-ops/multi-db-strategy.md` §1–§4 without refreshing that test's transcribed `expected*`/checksum. Not introduced by this branch; flagged separately to the PM since fixing it means editing a file already on `main`, out of scope for this PR.

## Who did the work

- **Renata Cole** (03 Engineering & Delivery, lead) — suite design and implementation, all fixes in the catch list above, empirical verification against live Postgres/CockroachDB/SQLite.
- **Oren Castellan** (03, QA/Detail Hawk) — review flagging the TOCTOU pattern and the nil-slice serialization issue on both PR #6 and PR #8.
- **Nolan Reyes** (03, ops/failure-mode reviewer) — review flagging the missing retry backoff and the `MaxOpenConns(1)` pool-queue contention scope note.
- **Priya Nandakumar** (05 Data Ops, lead) — authored Amendments A3/A4/A5 and the §7 conformance requirement this suite implements.
- **Yusuf Karadag** (05, Detail Hawk) — objection turn on Amendment A4 (five objections, all accepted).
- Model: Claude Sonnet 5, via Claude Code, orchestrated per `02-ai-security-architecture/planning-approach.md`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_011Cuba4JUvrsQCrTdUj4yzk
Bash gh pr diff 16 --repo wtvamp/loginid-takehome
input
{
  "command": "gh pr diff 16 --repo wtvamp/loginid-takehome"
}
result
<persisted-output>
Output too large (33.3KB). Full output saved to: /Users/warrenthompson/.claude/projects/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/tool-results/b2ld1oje7.txt

Preview (first 2KB):
diff --git a/internal/dao/conformance/conformance_test.go b/internal/dao/conformance/conformance_test.go
new file mode 100644
index 0000000..f24464b
--- /dev/null
+++ b/internal/dao/conformance/conformance_test.go
@@ -0,0 +1,568 @@
+package conformance
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"loginid-takehome/internal/dao"
+	"loginid-takehome/internal/model"
+)
+
+func strp(s string) *string { return &s }
+
+// ---- Search parity (multi-db-strategy.md §7's own minimum: "identical
+// result sets and identical ordering for the same Search across
+// backends"; §6.3 collation; §6.4 LIKE escaping; §6.5 Country
+// normalization — the three defects that already bit design once each). ----
+
+func TestConformance_SearchParityAcrossBackends(t *testing.T) {
+	// Fixture seeded identically on every backend via each backend's own
+	// Create — never a caller-supplied ID, since neither backend exposes
+	// that path. Rows are matched across backends by Name (unique in this
+	// fixture set), never by ID: ID generation is backend-specific
+	// (server-side on Postgres/CockroachDB, Go-side on SQLite), so a
+	// literal ID comparison would fail every run regardless of whether
+	// Search itself behaves correctly (Tobias's objection, refinement/
+	// LT-38.md).
+	fixture := []model.UserProfile{
+		{Name: "Alice Anderson", Region: strp("California"), Country: strp("US"), Source: "direct"},
+		{Name: "alice zephyr", Region: strp("Texas"), Country: strp("us"), Source: "direct"}, // lowercase input on write, checks §6.5 normalization
+		{Name: "Bob Brown", Region: strp("California"), Country: strp("US"), Source: "direct"},
+		{Name: "50% Off Corp", Region: strp("California"), Country: strp("US"), Source: "direct"},
+	}
+
+	for _, b := range backends {
+		t.Run(b.driver, func(t *testing.T) {
+			repo := b.newFix(t).repo
+			ctx := context.Background()
+
+			for _, p := range fixture {
+				pCopy := p
...
</persisted-output>
Sep 12, 9:02 PM PDTOren Castellan
Read /Users/warrenthompson/.claude/projects/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b
input
{
  "file_path": "/Users/warrenthompson/.claude/projects/-Users-warrenthompson-Source-LoginID-03-engineering-delivery/35c20edc-753d-4d96-b512-7114684706eb/tool-results/b2ld1oje7.txt"
}
result
1	diff --git a/internal/dao/conformance/conformance_test.go b/internal/dao/conformance/conformance_test.go
2	new file mode 100644
3	index 0000000..f24464b
4	--- /dev/null
5	+++ b/internal/dao/conformance/conformance_test.go
6	@@ -0,0 +1,568 @@
7	+package conformance
8	+
9	+import (
10	+	"context"
11	+	"errors"
12	+	"strings"
13	+	"sync"
14	+	"testing"
15	+	"time"
16	+
17	+	"loginid-takehome/internal/dao"
18	+	"loginid-takehome/internal/model"
19	+)
20	+
21	+func strp(s string) *string { return &s }
22	+
23	+// ---- Search parity (multi-db-strategy.md §7's own minimum: "identical
24	+// result sets and identical ordering for the same Search across
25	+// backends"; §6.3 collation; §6.4 LIKE escaping; §6.5 Country
26	+// normalization — the three defects that already bit design once each). ----
27	+
28	+func TestConformance_SearchParityAcrossBackends(t *testing.T) {
29	+	// Fixture seeded identically on every backend via each backend's own
30	+	// Create — never a caller-supplied ID, since neither backend exposes
31	+	// that path. Rows are matched across backends by Name (unique in this
32	+	// fixture set), never by ID: ID generation is backend-specific
33	+	// (server-side on Postgres/CockroachDB, Go-side on SQLite), so a
34	+	// literal ID comparison would fail every run regardless of whether
35	+	// Search itself behaves correctly (Tobias's objection, refinement/
36	+	// LT-38.md).
37	+	fixture := []model.UserProfile{
38	+		{Name: "Alice Anderson", Region: strp("California"), Country: strp("US"), Source: "direct"},
39	+		{Name: "alice zephyr", Region: strp("Texas"), Country: strp("us"), Source: "direct"}, // lowercase input on write, checks §6.5 normalization
40	+		{Name: "Bob Brown", Region: strp("California"), Country: strp("US"), Source: "direct"},
41	+		{Name: "50% Off Corp", Region: strp("California"), Country: strp("US"), Source: "direct"},
42	+	}
43	+
44	+	for _, b := range backends {
45	+		t.Run(b.driver, func(t *testing.T) {
46	+			repo := b.newFix(t).repo
47	+			ctx := context.Background()
48	+
49	+			for _, p := range fixture {
50	+				pCopy := p
51	+				if _, err := repo.Profiles().Create(ctx, &pCopy); err != nil {
52	+					t.Fatalf("seeding fixture %q: %v", p.Name, err)
53	+				}
54	+			}
55	+
56	+			t.Run("name search is case-insensitive for ASCII (§7 identical rows)", func(t *testing.T) {
57	+				results, total, err := repo.Profiles().Search(ctx, dao.ProfileQuery{Name: strp("alice")})
58	+				if err != nil {
59	+					t.Fatalf("Search: %v", err)
60	+				}
61	+				if total != 2 {
62	+					t.Fatalf("total = %d, want 2 (both Alice rows, case-insensitive)", total)
63	+				}
64	+				names := map[string]bool{}
65	+				for _, r := range results {
66	+					names[r.Name] = true
67	+				}
68	+				if !names["Alice Anderson"] || !names["alice zephyr"] {
69	+					t.Errorf("Search did not return both Alice rows: %+v", results)
70	+				}
71	+			})
72	+
73	+			t.Run("LIKE escaping treats %% as literal (§6.4)", func(t *testing.T) {
74	+				results, total, err := repo.Profiles().Search(ctx, dao.ProfileQuery{Name: strp("50%")})
75	+				if err != nil {
76	+					t.Fatalf("Search: %v", err)
77	+				}
78	+				if total != 1 || len(results) != 1 || results[0].Name != "50% Off Corp" {
79	+					t.Errorf("Search(%q) = %d results %+v, want exactly the literal-%% row", "50%", total, results)
80	+				}
81	+			})
82	+
83	+			t.Run("Country is normalized to uppercase at write (§6.5)", func(t *testing.T) {
84	+				_, total, err := repo.Profiles().Search(ctx, dao.ProfileQuery{Country: strp("US")})
85	+				if err != nil {
86	+					t.Fatalf("Search: %v", err)
87	+				}
88	+				if total != len(fixture) {
89	+					t.Errorf("Country search for %q matched %d, want %d — a lowercase 'us' written to the row must normalize to 'US' or it becomes invisible to this query", "US", total, len(fixture))
90	+				}
91	+			})
92	+
93	+			t.Run("ordering is LOWER(name) ASC, id ASC (§6.3 collation)", func(t *testing.T) {
94	+				results, _, err := repo.Profiles().Search(ctx, dao.ProfileQuery{Region: strp("California")})
95	+				if err != nil {
96	+					t.Fatalf("Search: %v", err)
97	+				}
98	+				if len(results) < 2 {
99	+					t.Fatalf("expected at least 2 California rows, got %d", len(results))
100	+				}
101	+				for i := 1; i < len(results); i++ {
102	+					prevLower, curLower := lower(results[i-1].Name), lower(results[i].Name)
103	+					if curLower < prevLower {
104	+						t.Errorf("ordering violated at index %d: %q came after %q", i, results[i].Name, results[i-1].Name)
105	+					}
106	+				}
107	+			})
108	+		})
109	+	}
110	+}
111	+
112	+func lower(s string) string {
113	+	b := []byte(s)
114	+	for i, c := range b {
115	+		if c >= 'A' && c <= 'Z' {
116	+			b[i] = c + 32
117	+		}
118	+	}
119	+	return string(b)
120	+}
121	+
122	+// ---- Empty-string rejection per ProfileQuery field, one named test case
123	+// per field, per the "absent, never empty" convention (§7 minimum). ----
124	+
125	+func TestConformance_EmptyStringRejection_Name(t *testing.T) {
126	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
127	+		_, _, err := repo.Profiles().Search(context.Background(), dao.ProfileQuery{Name: strp("")})
128	+		if !errors.Is(err, dao.ErrInvalidQuery) {
129	+			t.Errorf("empty Name = %v, want ErrInvalidQuery", err)
130	+		}
131	+	})
132	+}
133	+
134	+func TestConformance_EmptyStringRejection_Phone(t *testing.T) {
135	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
136	+		_, _, err := repo.Profiles().Search(context.Background(), dao.ProfileQuery{Phone: strp("")})
137	+		if !errors.Is(err, dao.ErrInvalidQuery) {
138	+			t.Errorf("empty Phone = %v, want ErrInvalidQuery", err)
139	+		}
140	+	})
141	+}
142	+
143	+func TestConformance_EmptyStringRejection_Region(t *testing.T) {
144	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
145	+		_, _, err := repo.Profiles().Search(context.Background(), dao.ProfileQuery{Region: strp("")})
146	+		if !errors.Is(err, dao.ErrInvalidQuery) {
147	+			t.Errorf("empty Region = %v, want ErrInvalidQuery", err)
148	+		}
149	+	})
150	+}
151	+
152	+func TestConformance_EmptyStringRejection_Country(t *testing.T) {
153	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
154	+		_, _, err := repo.Profiles().Search(context.Background(), dao.ProfileQuery{Country: strp("")})
155	+		if !errors.Is(err, dao.ErrInvalidQuery) {
156	+			t.Errorf("empty Country = %v, want ErrInvalidQuery", err)
157	+		}
158	+	})
159	+}
160	+
161	+// ---- Sentinel parity, one named test case per §4 condition, across all
162	+// three backends (this is the enforcement site LT-36/LT-37 were waiting
163	+// on). ----
164	+
165	+// Three assertions, per 05's amendment A4 (revised after Yusuf's
166	+// objection turn) — the pair alone can't distinguish a correct
167	+// implementation from one that returns one sentinel for everything, and
168	+// the third closes the specific bug the first version of A4 itself had:
169	+// a permissive validator (uuid.Parse-shaped) accepts non-canonical forms
170	+// (uppercase, brace-wrapped, unhyphenated) that Postgres normalizes and
171	+// finds, while SQLite's TEXT column byte-compares and misses — silently
172	+// moving the divergence rather than closing it.
173	+
174	+func TestConformance_Sentinel_ErrNotFound(t *testing.T) {
175	+	// A well-formed, CANONICAL, but nonexistent UUID — the realistic
176	+	// "missing row" case ErrNotFound is for.
177	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
178	+		_, err := repo.Profiles().Get(context.Background(), "00000000-0000-0000-0000-000000000000")
179	+		if !errors.Is(err, dao.ErrNotFound) {
180	+			t.Errorf("Get on well-formed missing row = %v, want ErrNotFound", err)
181	+		}
182	+	})
183	+}
184	+
185	+func TestConformance_Sentinel_MalformedID(t *testing.T) {
186	+	// A syntactically malformed id must be ErrInvalidArgument on every
187	+	// backend — a caller bug, not a data outcome. Load-bearing for
188	+	// DeleteProfile specifically: this contract treats ErrNotFound from a
189	+	// delete as "already gone, success" — if a malformed id read as
190	+	// ErrNotFound, a typo'd subject-deletion request would report
191	+	// completed while nothing was deleted.
192	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
193	+		_, err := repo.Profiles().Get(context.Background(), "does-not-exist")
194	+		if !errors.Is(err, dao.ErrInvalidArgument) {
195	+			t.Errorf("Get with a malformed id = %v, want ErrInvalidArgument", err)
196	+		}
197	+	})
198	+}
199	+
200	+func TestConformance_Sentinel_EmptyID(t *testing.T) {
201	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
202	+		_, err := repo.Profiles().Get(context.Background(), "")
203	+		if !errors.Is(err, dao.ErrInvalidArgument) {
204	+			t.Errorf("Get with an empty id = %v, want ErrInvalidArgument", err)
205	+		}
206	+	})
207	+}
208	+
209	+func TestConformance_Sentinel_NonCanonicalWellFormedID(t *testing.T) {
210	+	// Same 16 bytes as a real, existing row's id, but uppercase — a form
211	+	// uuid.Parse would accept as "well-formed" that Postgres normalizes
212	+	// and SQLite does not. Must be rejected identically to any other
213	+	// malformed input, not silently accepted and then behave differently
214	+	// per backend.
215	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
216	+		ctx := context.Background()
217	+		p, err := repo.Profiles().Create(ctx, &model.UserProfile{Name: "Canonical ID Test", Source: "direct"})
218	+		if err != nil {
219	+			t.Fatalf("creating profile: %v", err)
220	+		}
221	+		nonCanonical := strings.ToUpper(p.ID)
222	+		_, err = repo.Profiles().Get(ctx, nonCanonical)
223	+		if !errors.Is(err, dao.ErrInvalidArgument) {
224	+			t.Errorf("Get with a non-canonical (uppercase) but otherwise valid id = %v, want ErrInvalidArgument — the validator must accept only the canonical lowercase form", err)
225	+		}
226	+	})
227	+}
228	+
229	+func TestConformance_Sentinel_ErrDuplicateUsername(t *testing.T) {
230	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
231	+		ctx := context.Background()
232	+		p, err := repo.Profiles().Create(ctx, &model.UserProfile{Name: "Dup Test", Source: "direct"})
233	+		if err != nil {
234	+			t.Fatalf("creating profile: %v", err)
235	+		}
236	+		methodID := seededMethodIDFor(t, repo)
237	+		if _, err := repo.Credentials().Create(ctx, &model.UserCredential{UserID: p.ID, Username: "dupuser", MethodID: methodID, SecretState: "none"}); err != nil {
238	+			t.Fatalf("first credential create: %v", err)
239	+		}
240	+		_, err = repo.Credentials().Create(ctx, &model.UserCredential{UserID: p.ID, Username: "DUPUSER", MethodID: methodID, SecretState: "none"})
241	+		if !errors.Is(err, dao.ErrDuplicateUsername) {
242	+			t.Errorf("duplicate (case-folded) username = %v, want ErrDuplicateUsername", err)
243	+		}
244	+	})
245	+}
246	+
247	+func TestConformance_Sentinel_ErrInvalidMethod(t *testing.T) {
248	+	// Also A3's second new assertion: without them, this is exactly the
249	+	// sentinel that silently never fires on SQLite if the foreign_keys
250	+	// pragma isn't actually active on every connection.
251	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
252	+		ctx := context.Background()
253	+		p, err := repo.Profiles().Create(ctx, &model.UserProfile{Name: "Invalid Method Test", Source: "direct"})
254	+		if err != nil {
255	+			t.Fatalf("creating profile: %v", err)
256	+		}
257	+		_, err = repo.Credentials().Create(ctx, &model.UserCredential{UserID: p.ID, Username: "invalidmethoduser", MethodID: "00000000-0000-0000-0000-00000000dead", SecretState: "none"})
258	+		if !errors.Is(err, dao.ErrInvalidMethod) {
259	+			t.Errorf("unknown method_id = %v, want ErrInvalidMethod — if this is nil, the FK is not enforced on this backend", err)
260	+		}
261	+	})
262	+}
263	+
264	+func TestConformance_Sentinel_ErrInvalidQuery(t *testing.T) {
265	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
266	+		_, _, err := repo.Profiles().Search(context.Background(), dao.ProfileQuery{Limit: 101})
267	+		if !errors.Is(err, dao.ErrInvalidQuery) {
268	+			t.Errorf("Limit>100 = %v, want ErrInvalidQuery", err)
269	+		}
270	+	})
271	+}
272	+
273	+func TestConformance_Sentinel_ErrInvalidArgument(t *testing.T) {
274	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
275	+		_, err := repo.Profiles().Create(context.Background(), &model.UserProfile{ID: "caller-supplied", Name: "X", Source: "direct"})
276	+		if !errors.Is(err, dao.ErrInvalidArgument) {
277	+			t.Errorf("caller-supplied ID = %v, want ErrInvalidArgument", err)
278	+		}
279	+	})
280	+}
281	+
282	+func TestConformance_Sentinel_ErrInvalidCredential(t *testing.T) {
283	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
284	+		ctx := context.Background()
285	+		p, err := repo.Profiles().Create(ctx, &model.UserProfile{Name: "Secret State Test", Source: "direct"})
286	+		if err != nil {
287	+			t.Fatalf("creating profile: %v", err)
288	+		}
289	+		methodID := seededMethodIDFor(t, repo)
290	+		_, err = repo.Credentials().Create(ctx, &model.UserCredential{UserID: p.ID, Username: "secretstatetest", MethodID: methodID, SecretState: "set"})
291	+		if !errors.Is(err, dao.ErrInvalidCredential) {
292	+			t.Errorf("secret_state=set with no secret = %v, want ErrInvalidCredential", err)
293	+		}
294	+	})
295	+}
296	+
297	+// ---- §6 item 12: requires_secret / secret_state relationship ----
298	+
299	+func TestConformance_RequiresSecretRelationship(t *testing.T) {
300	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
301	+		ctx := context.Background()
302	+		// The seeded 'password' method has requires_secret=true, so this
303	+		// case can't reach the relationship directly through DDL alone —
304	+		// the DAO-layer check (§6 item 12) is what this test proves,
305	+		// covering the case DDL structurally cannot: a method with
306	+		// requires_secret=false paired with secret_state='set'.
307	+		p, err := repo.Profiles().Create(ctx, &model.UserProfile{Name: "Requires Secret Test", Source: "direct"})
308	+		if err != nil {
309	+			t.Fatalf("creating profile: %v", err)
310	+		}
311	+		passkeyMethod, err := createAuthMethod(t, repo, "passkey-"+p.ID, false)
312	+		if err != nil {
313	+			t.Fatalf("creating passkey auth_method: %v", err)
314	+		}
315	+		hashAlgo := "argon2id"
316	+		hashCost := 3
317	+		_, err = repo.Credentials().Create(ctx, &model.UserCredential{
318	+			UserID: p.ID, Username: "requiressecrettest", MethodID: passkeyMethod, SecretState: "set",
319	+			Secret: []byte("hash"), HashAlgo: &hashAlgo, HashCost: &hashCost,
320	+		})
321	+		if !errors.Is(err, dao.ErrInvalidCredential) {
322	+			t.Errorf("secret_state=set against a requires_secret=false method = %v, want ErrInvalidCredential (§6 item 12)", err)
323	+		}
324	+	})
325	+}
326	+
327	+// ---- §6 item 10: is_active enforcement, and deactivation doesn't
328	+// invalidate existing credentials. ----
329	+
330	+func TestConformance_IsActiveEnforcement(t *testing.T) {
331	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
332	+		ctx := context.Background()
333	+		inactiveMethod, err := createAuthMethod(t, repo, "inactive-method", true)
334	+		if err != nil {
335	+			t.Fatalf("creating auth_method: %v", err)
336	+		}
337	+		if err := deactivateMethod(t, repo, inactiveMethod); err != nil {
338	+			t.Fatalf("deactivating method: %v", err)
339	+		}
340	+
341	+		p, err := repo.Profiles().Create(ctx, &model.UserProfile{Name: "Is Active Test", Source: "direct"})
342	+		if err != nil {
343	+			t.Fatalf("creating profile: %v", err)
344	+		}
345	+		_, err = repo.Credentials().Create(ctx, &model.UserCredential{UserID: p.ID, Username: "isactivetest", MethodID: inactiveMethod, SecretState: "none"})
346	+		if !errors.Is(err, dao.ErrInvalidMethod) {
347	+			t.Errorf("creating a credential against a deactivated method = %v, want ErrInvalidMethod", err)
348	+		}
349	+	})
350	+}
351	+
352	+func TestConformance_DeactivationDoesNotInvalidateExistingCredentials(t *testing.T) {
353	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
354	+		ctx := context.Background()
355	+		method, err := createAuthMethod(t, repo, "will-deactivate", true)
356	+		if err != nil {
357	+			t.Fatalf("creating auth_method: %v", err)
358	+		}
359	+		p, err := repo.Profiles().Create(ctx, &model.UserProfile{Name: "Existing Cred Test", Source: "direct"})
360	+		if err != nil {
361	+			t.Fatalf("creating profile: %v", err)
362	+		}
363	+		cred, err := repo.Credentials().Create(ctx, &model.UserCredential{UserID: p.ID, Username: "existingcredtest", MethodID: method, SecretState: "none"})
364	+		if err != nil {
365	+			t.Fatalf("creating credential: %v", err)
366	+		}
367	+
368	+		if err := deactivateMethod(t, repo, method); err != nil {
369	+			t.Fatalf("deactivating method: %v", err)
370	+		}
371	+
372	+		got, err := repo.Credentials().GetByUsername(ctx, "existingcredtest")
373	+		if err != nil {
374	+			t.Errorf("existing credential against a since-deactivated method should still read back, got %v", err)
375	+		}
376	+		if got == nil || got.ID != cred.ID {
377	+			t.Error("read-back credential does not match the one created")
378	+		}
379	+	})
380	+}
381	+
382	+// ---- §6.2: cascade delete asymmetry (A3's first new assertion). ----
383	+
384	+func TestConformance_ProfileDeleteCascadesToCredentials(t *testing.T) {
385	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
386	+		ctx := context.Background()
387	+		methodID := seededMethodIDFor(t, repo)
388	+		p, err := repo.Profiles().Create(ctx, &model.UserProfile{Name: "Cascade Test", Source: "direct"})
389	+		if err != nil {
390	+			t.Fatalf("creating profile: %v", err)
391	+		}
392	+		if _, err := repo.Credentials().Create(ctx, &model.UserCredential{UserID: p.ID, Username: "cascadetest", MethodID: methodID, SecretState: "none"}); err != nil {
393	+			t.Fatalf("creating credential: %v", err)
394	+		}
395	+
396	+		if err := repo.Profiles().Delete(ctx, p.ID); err != nil {
397	+			t.Fatalf("Delete: %v", err)
398	+		}
399	+
400	+		list, err := repo.Credentials().ListByUserID(ctx, p.ID)
401	+		if err != nil {
402	+			t.Fatalf("ListByUserID: %v", err)
403	+		}
404	+		if len(list) != 0 {
405	+			t.Error("credential survived profile deletion — ON DELETE CASCADE not enforced on this backend (A3)")
406	+		}
407	+	})
408	+}
409	+
410	+func TestConformance_CredentialDeleteLeavesProfileStanding(t *testing.T) {
411	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
412	+		ctx := context.Background()
413	+		methodID := seededMethodIDFor(t, repo)
414	+		p, err := repo.Profiles().Create(ctx, &model.UserProfile{Name: "Asymmetry Test", Source: "direct"})
415	+		if err != nil {
416	+			t.Fatalf("creating profile: %v", err)
417	+		}
418	+		cred, err := repo.Credentials().Create(ctx, &model.UserCredential{UserID: p.ID, Username: "asymmetrytest", MethodID: methodID, SecretState: "none"})
419	+		if err != nil {
420	+			t.Fatalf("creating credential: %v", err)
421	+		}
422	+
423	+		if err := repo.Credentials().Delete(ctx, cred.ID); err != nil {
424	+			t.Fatalf("Delete credential: %v", err)
425	+		}
426	+
427	+		if _, err := repo.Profiles().Get(ctx, p.ID); err != nil {
428	+			t.Errorf("profile should survive its credential's deletion, got %v", err)
429	+		}
430	+	})
431	+}
432	+
433	+// ---- §7 retention/deletion minimums. ----
434	+
435	+func TestConformance_DeleteExpired_InvalidClass(t *testing.T) {
436	+	runAgainstAllBackends(t, func(t *testing.T, repo dao.Repository) {
437	+		_, err := repo.DeleteExpired(context.Background(), dao.RetentionClass(""), time.Now(), 100)
438	+		if !errors.Is(err, dao.ErrInvalidArgument) {
439	+			t.Errorf("zero-value RetentionClass = %v, want ErrInvalidArgument", err)
440	+		}
441	+	})
442	+}
443	+
444	+func TestConformance_DeleteExpired_MaxRowsBounds(t *testing.T) {
445	+	runAgainstAllBackends(
… truncated (20 KB cap)
Sep 12, 9:03 PM PDTOren Castellan
I read PR #16 (`gh pr view 16` / `gh pr diff 16`) and went through `conformance_test.go`, `helpers_test.go`, and `testutil_test.go` line by line, tracing each assertion back to what it actually checks against the DB rather than what its name or the PR description claims. Findings, most important first: 1. **`TestConformance_DeleteWritesExactlyOneDeletionLogRow` doesn't check `deletion_log` at all** (`internal/dao/conformance/conformance_test.go`, the function starting around line 492). The test name and the PR description both claim this asserts "exactly-one `deletion_log` row per delete." The body only calls `DeleteProfile` twice and checks that the second call returns `ErrNotFound` — that proves the row is gone, not that exactly one log row was written. A backend that wrote zero, one, or three `deletion_log` rows per delete would pass this test identically; idempotency of the delete and correctness of the audit trail are two different guarantees and this only tests the first. The fixture already carries an `admin *sql.DB` per backend for exactly this kind of out-of-interface check (see `createAuthMethod`/`countProfiles` in `helpers_test.go`) — this test should do `SELECT COUNT(*) FROM deletion_log WHERE ...` against `fx.admin` before and after, the same pattern `countProfiles` already establishes. As written, this is a claimed conformance guarantee with no actual assertion behind it — exactly the kind of gap the suite exists to eliminate elsewhere. 2. **Ordering test doesn't exercise its own stated tie-break** (`TestConformance_SearchParityAcrossBackends`, the `"ordering is LOWER(name) ASC, id ASC"` subtest, around line 86). The fixture has no two rows with the same `LOWER(name)`, so the `id ASC` half of the ordering contract in §6.3 is never exercised — a backend that ignores `id` entirely as a tie-break would pass. Low severity since the case is rare, but it's a real gap between what the subtest name asserts and what it can actually detect; a fifth fixture row with a name that's a case-only duplicate of an existing one would close it. 3. **`TestConformance_DeleteExpired_MaxRowsBounds` only tests the excluded boundary, not the included one** (around line 437). It confirms `maxRows=0` and `maxRows=10_001` are rejected, but never confirms `maxRows=1` or `maxRows=10_000` (the presumed valid edges) succeed. This is covered incidentally by other tests using `maxRows=100`, so it's not a live gap today, but as an off-by-one check in isolation it can't distinguish "boundary is 1–10000" from "boundary is 5–9999" or similar — worth a one-line addition rather than leaving it to be caught by unrelated tests. Everything else I traced cleanly: the `t.Cleanup` LIFO ordering in both `newPostgresFamilyFixture` and `newSQLiteFixture` is correct (repo closes before schema drop / before admin close, which is required since admin performs the drop and SQLite's shared in-memory cache dies when the last connection closes — the inline comment for the SQLite case is accurate); the cancelled-context test correctly guards the nil-pointer dereference on `OldestSurvivingAt`; sentinel-error assertions consistently use `errors.Is`, never bare equality; and the non-canonical-UUID test is a genuine, well-targeted regression test for the exact silent-divergence bug the catch list describes. `fixtureByRepo` is an unbounded package-global map with no delete-on-cleanup, but that's a memory-only leak scoped to one test binary run, not a correctness issue worth blocking on. Finding 1 is the one I'd hold the PR on — it's a mismatch between a stated behavioral guarantee (both in the test name and in the PR's own coverage list) and what's actually verified, which is the specific failure mode this whole suite was built to catch in other people's code. Files reviewed: PR #16, `internal/dao/conformance/conformance_test.go`, `internal/dao/conformance/helpers_test.go`, `internal/dao/conformance/testutil_test.go` (all new in this diff, no other files touched).