{
"command": "pwd",
"description": "Change to track directory"
}/Users/warrenthompson/Source/LoginID/05-data-ops
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/CLAUDE.md"
}1 # Track: Data Ops 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 Own the data model and the multi-database strategy that question 1 requires, plus the data governance implications of storing `user_profile` PII and `user_credential` secrets. The engineering track implements this track's design; this track does not write the Go DAO code itself. 8 9 ## Owns 10 11 - Schema design for `user_profile` (name, address, phone) and `user_credential` (username, method, password), including how `method` is modeled (e.g., an enum for password vs. other auth methods) and how the two entities relate. 12 - The multi-database abstraction strategy: how one DAO interface serves PostgreSQL/CockroachDB and SQLite without diverging behavior — e.g., a repository interface with per-driver implementations, and which SQL dialect differences (upserts, autoincrement, JSON columns) need to be handled. 13 - Migration strategy: how schema changes would be versioned and applied across the supported databases. 14 - Data governance for PII: retention policy for `user_profile` data and for any cached data pulled from the third-party IDP connector's `/identity` endpoint, and how that interacts with credential storage (`user_credential` should never store PII, and the reverse). 15 - Indexing/query design to support "search and retrieve" from question 2 (e.g., what fields the API needs to search on, and what that implies for schema indexes). 16 17 ## Does not own 18 19 - Access control and transport security for that data — that's `../02-ai-security-architecture/CLAUDE.md`. 20 - Writing the DAO code itself — that's `../03-engineering-delivery/CLAUDE.md`; this track hands it a schema and an interface contract to implement. 21 - Where the databases actually run and how migrations are executed in a pipeline — that's `../04-infra-devops/CLAUDE.md`. 22 23 ## Deliverables 24 25 - Schema definitions (DDL or equivalent) for `user_profile` and `user_credential`, across the target databases. 26 - A written multi-database abstraction strategy the engineering track can implement directly. 27 - A migration approach. 28 - A short PII retention/governance note. 29 30 ## AI tooling note 31 32 Record which AI tool/workflow was used to produce this track's schema and strategy design and how. 33 34 ## Status 35 36 No data-ops work has started. This is the track the engineering DAO code most directly depends on — its schema and interface contract should land early. 37 38 <!-- profile-gen:start slug=priya-nandakumar --> 39 @profiles/priya-nandakumar/priya-nandakumar.md 40 <!-- profile-gen:end slug=priya-nandakumar --> 41
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/PLAN.md"
}1 # Track 05 — Data Ops: Planning Phase Plan 2 3 Lead: Priya Nandakumar (opus/high for this phase). Track directory: `05-data-ops/`. Org plan: `../PLAN.md`. Board row: `../PLANNING.md`. Rules, palette, patterns, hire procedure: `../CASTING.md`. Coordination approach: `../02-ai-security-architecture/planning-approach.md`. 4 5 ## Context 6 7 All three of the assignment's questions sit on top of one schema and one DAO contract, and this track owns both. That is the whole reason 05 is on the first-wave critical path: 03 cannot write the DAO until the contract is stable, and 04 cannot plan migration execution until the migration approach is named. 8 9 The first-wave design work already exists here as prose — `research-data-ops-best-practices.md` (sourced research), `multi-db-strategy.md` (interface contract and schema shape), `pii-governance.md` (retention and non-conflation rules). What does not exist is the thing 03 actually needs. `planning-approach.md` records my own commitment on this and Marcus adopted it project-wide: a hand-off is implementable only if the receiver can act on it **without re-deriving the sender's reasoning**. `multi-db-strategy.md` today describes `Search(ctx, query) (results []UserProfile, total int, error)` in a sentence. It does not say what `query` is, which fields are nullable, what `Update` does when the row is gone, or whether `total` is the filtered count or the page count. Renata would have to invent all of that, and inventing it is a design decision that belongs to this track, not hers. 10 11 So the deliverable this phase is aimed at is narrow and specific: turn the prose contract into a Go-shaped contract — interface signatures, field-level schema with nullability and types, error semantics, and explicit out-of-scope notes telling 03 what it must not redesign. Everything else in this plan either feeds that artifact or unblocks 04. 12 13 The planning-phase rule holds throughout: this is a Go-*shaped* contract written inside a markdown file. No `.go`, no `.sql`, no `go.mod`, until Warren's explicit go-ahead. A fenced Go block inside `multi-db-strategy.md` is the design; a file the compiler would accept is code. 14 15 ## 1. Deliverables and tasks 16 17 Six deliverables, each sized to one Jira Story under the track's Epic in project `LT`. Stories are listed in dependency order; S1 is the first Phase 3 deliverable and the one everything downstream waits on. Acceptance criterion for every story is the hand-off standard above. 18 19 ### S1 — Go-shaped contract in `multi-db-strategy.md` (the critical-path deliverable) 20 21 Extend `multi-db-strategy.md` in place — it stays the single source of truth, no new parallel doc. Adds, as fenced Go-shaped blocks and tables rather than prose: 22 23 - **Interface signatures** for `ProfileRepository`, `CredentialRepository`, `AuthMethodRepository` — exact method names, parameter and return types, and the `ProfileQuery` / `ProfileSearchResult` types `Search` takes and returns (shape ruled in S2). 24 - **Error semantics**, which today are one sentence about `ErrNotFound`. Needs the full sentinel set and when each is returned: `ErrNotFound`, `ErrDuplicateUsername` (unique-constraint violation on `user_credential.username`, which both backends must translate out of their driver-specific error into the shared sentinel), `ErrInvalidMethod` (FK violation on `method_id`), and the rule that everything else is wrapped with `%w` and passed through. Also: does `Update` on a missing row return `ErrNotFound` or upsert-create? (My position going in: `Update` is a portable upsert per the existing doc, so it creates — which means the doc must say so loudly, because "Update creates" is exactly the surprise that produces a bug in 03.) 25 - **Field-level schema table per entity** — column, Go type, SQL type per backend family, nullable yes/no, constraint, and a one-line note. Nullability is where this contract is most likely to be wrong and most expensive to fix, so it gets its own reviewer (S3). 26 - **Context and cancellation**: every method takes `ctx` first and is expected to honour cancellation; no method holds a transaction across calls. If the service layer needs a multi-statement transaction, that is a follow-up on the contract, not something 03 improvises — stated explicitly. 27 - **Explicitly out of scope for 03** — a numbered list so there is no ambiguity about what 03 must not redesign: the PII/credential separation (governance control, `pii-governance.md`), the UUID-everywhere PK strategy, the `auth_method` lookup table rather than a native enum, the `ON CONFLICT DO UPDATE` upsert form, the documented SQLite fuzzy-search parity gap, and the two-package (`postgres` serving Postgres + CockroachDB, `sqlite` separate) split. Each with a one-line "why, and who to flag if it doesn't fit." 28 - **Alignment note to 03's published factory**: `../PLANNING.md` service boundaries names `dao.New(driver, dsn string) (dao.Repository, error)` — a single `Repository` return type, while this track specifies three interfaces. Resolve it in the contract (my expected resolution: `dao.Repository` is a small composite that exposes `Profiles()`, `Credentials()`, `Methods()`, keeping Renata's factory signature stable exactly as she declared it) rather than leaving two tracks each thinking the other will bend. 29 30 Consumes: S2 (query shape), S3 (nullability review), and the S4 ruling. Produces the 05→03 hand-off. Story closes when S6's cold read comes back clean. 31 32 ### S2 — `Search()` query shape decision 33 34 The one genuinely open design question in the contract, and the one with the most ways to be quietly wrong. Decides: the `ProfileQuery` field set and which fields are optional; match semantics per field (name partial/fuzzy, phone exact-or-prefix on the E.164-normalized value, region/country exact); how empty and multiple filters combine (AND, and whether a wholly empty query is a legal "list everything" or an error); pagination as limit/offset versus a keyset cursor, with a maximum page size; sort order and whether it is stable enough for offset pagination to be meaningful at all; and what `total` means — total matching rows, or rows on this page. Also settles how the documented SQLite parity gap surfaces to a caller: identical results with worse performance, or ranked-vs-unranked differences a caller could observe. 35 36 Pattern: **adversarial pair** (`../CASTING.md` §5 names this decision by name). Artifact: `decisions/search-query-shape.md`, folded into S1. 37 38 ### S3 — Multi-DB abstraction ruling 39 40 `multi-db-strategy.md` already recommends two packages behind one interface and rejects dialect branching. That recommendation was reached by one agent reading sources. The catalog names this decision for **three hats**, and it is worth the three turns because it is the structural choice 03 builds the entire DAO around — codegen (sqlc) is a real third option that was never actually argued, only mentioned. Possible outcomes: the existing recommendation is confirmed (most likely, and the confirmation is then a documented ruling rather than an assumption), or codegen displaces hand-written SQL, which changes what S1's contract has to pin down. 41 42 Artifact: `decisions/multi-db-abstraction.md` with the three ≤300-word positions, my synthesis, and the pattern footer (model per seat, hire turns consumed) that 02 added to every pattern. 43 44 ### S4 — Migration approach hand-off for 04 45 46 `../PLANNING.md` lists 04 as blocked on "05's migration tool/approach". `multi-db-strategy.md` has a migration section, but in Theo's terms it is a preference, not a mechanism. Produces `migration-approach.md` — a new file, because its receiver is 04, not 03, and folding an infra hand-off into the DAO contract would make both harder to read: 47 48 - Tool: goose (already reasoned in `multi-db-strategy.md`; golang-migrate acceptable if 03 has tooling preferences — one rotating-devil's-advocate objection turn, not a debate). 49 - Directory layout and naming for the shared series plus the named per-backend override set (currently exactly one override: the `pg_trgm` GIN index, omitted on SQLite). 50 - What 04 must provide: when migrations run relative to service start, whether the service fails closed if the schema is behind, migration-lock behaviour under multiple replicas, and the DB privileges the migration runner needs versus the runtime user. 51 - Seed data: the `auth_method` `"password"` row is a seeded row, not a migration constant — how it is applied and what makes it idempotent. 52 - Explicitly not owned here: where the databases run, how the pipeline invokes goose, environment topology. Named so 04 does not read a boundary into this doc that isn't there. 53 54 ### S5 — Retention windows and the deletion-job contract 55 56 `pii-governance.md` states the policy shape and stops at "a product/legal decision this track doesn't own outright." That is true and it is also not actionable — 04 cannot build a retention sweep against it. This story puts numbers in a table, marked as **proposed defaults for a POC pending a product/legal ruling**, and specifies the mechanism the numbers plug into: what a sweep selects on (`source`, `updated_at`), whether deletion is hard delete or tombstone, what cascades to `user_credential` (the FK is already cascade-delete, so deleting a profile silently destroys its credentials — deliberate and worth stating), and what a subject-deletion request touches versus a bulk retention sweep. Separate proposed windows for `source = 'direct'` and `source = 'idp_cache'`, with the `idp_cache` window shorter and the reasoning stated. 57 58 Owner seat: the Spreadsheet hire, who will not accept "a reasonable window" as an answer. Artifact: `pii-governance.md` extended with a retention table; policy shape only, execution stays 04's. 59 60 ### S6 — Cold-read legibility pass on the 05→03 contract 61 62 The catalog calls the **newcomer's question** pass the highest-ROI pattern in the project and names "every 05→03 hand-off" as its use. A hire who has not watched this contract being written reads S1 cold and lists what they could not act on without asking me. I fix every item or record why not. This is the acceptance test for S1 and for the hand-off standard I asked the project to adopt; failing to run it on my own output would be the most embarrassing available outcome. 63 64 Artifact: `decisions/handoff-legibility-05-03.md`. Runs after S1 drafts, gates S1 closing. 65 66 ## 2. Hire roster — 4 hires 67 68 Matches the suggested cast in `../CASTING.md` §3 exactly. Variance check against the fourteen hires already registered: Systems Cartographer, Storyteller, Detail Hawk and Spreadsheet each currently sit at 1× org-wide, so all four land at 2× — at the cap, none over it, and none repeated within this team. Rule 2 (same archetype, different function) holds in every case, stated per hire below. Rule 3 (lead's temperament opposite) is Saoirse. Rule 5: this roster spans serious↔playful and cautious↔bold both. Model: **sonnet** for all four; none of these seats makes a foundational call alone — I hold those at opus, which is the entire reason this session was relaunched. 69 70 Two seats in my debate plan have no archetype left in the palette (Designated Skeptic and Newcomer are both at 2× org-wide). Rather than burn a hire on a near-duplicate, I substitute within this roster and say so explicitly in §3 — the pattern protocols are about the role played, not the label worn. 71 72 ### Hire 1 — Anders Vogel · Systems Cartographer · serious, bold 73 74 Function: multi-DB abstraction — drafts the interface contract's structural shape and the Proposer seat on `Search()`. Distinct from Ines Dabrowski's Systems Cartographer function in 03 (Go module layout and service topology): Anders draws the boundary *inside* the DAO between interface and per-backend implementation; Ines draws boundaries between packages and binaries. Different artifacts, no overlap. 75 76 > Persona paragraph for `../CASTING.md` §4 (127 words): 77 > 78 > Calm and unusually direct for someone who thinks in diagrams; opens by drawing the boundary before anyone argues about what sits on either side of it. Decides by mapping the interaction surface first — what crosses this line, in which direction, how often — then choosing the abstraction that makes the smallest surface. Pushes back hard on any interface whose implementations must know about each other, and on abstractions justified by a backend nobody has committed to adding. Six years on a payments platform's storage layer where a leaky repository interface bled dialect assumptions into three services, then four years at a data-infrastructure vendor building connectors across five engines. Blind spot: abstracts past the concrete — he will design for the fourth backend before the second one works. Concedes cleanly, in writing, and moves on. 79 80 ### Hire 2 — Saoirse Byrne · Storyteller · playful, bold (my temperament opposite, rule 3) 81 82 Function: consumer's-eye reader and cold-read seat — argues the contract from the position of whoever has to use it, and runs the newcomer's-question pass on S1. Distinct from Imogen Hale's Storyteller function in 01 (product framing and narrative thesis): Saoirse's subject is a schema and an interface, not a market; her output is "here is where this hand-off stops being followable," not a README thesis. 83 84 > Persona paragraph for `../CASTING.md` §4 (133 words): 85 > 86 > Warm, quick, and cheerfully unbothered by being the least formal person in a schema review; narrates a design as the journey of one record — created here, read there, deleted when. Decides by walking the path end to end and noticing where the story skips a step, which is usually where the bug is. Pushes back on documents that are correct but unfollowable, and on any governance rule stated so abstractly that nobody could tell whether they had broken it. Five years writing developer documentation and SDK guides at an API company, three as a solutions engineer who watched customers misread her own docs in real time. Reads SQL comfortably and writes Go badly enough to be an honest proxy for a confused implementer. Blind spot: charming beats correct — the clearer story is not always the true one. Disagrees by retelling it. 87 88 ### Hire 3 — Yusuf Karadag · Detail Hawk · serious, cautious 89 90 Function: DDL and nullability review, plus the numbered-objection seat on `Search()`. Distinct from Oren Castellan's Detail Hawk function in 03 (Go code review — error semantics, off-by-one): Yusuf reviews the schema and contract *before* code exists; Oren reviews code after. `../CASTING.md` §2 names this exact pair as the allowed second use. 91 92 > Persona paragraph for `../CASTING.md` §4 (129 words): 93 > 94 > Quiet, exacting, and slower to speak than everyone else in the room because he is still reading the column list. Decides one field at a time: type, nullability, constraint, default, and what happens on the day the constraint is violated in production. Pushes back on any nullable column whose null has no stated meaning, on "we'll validate it in the application layer," and on error handling that collapses three distinct database failures into one generic message. Nine years as a database engineer at a healthcare data processor under audit conditions, then three on a platform team migrating a large schema across engines. Deep in Postgres constraint semantics and the places SQLite quietly disagrees. Blind spot: cannot see the forest — he will hold a release over a defaulted column. Objects in numbered lists and expects numbered answers. 95 96 ### Hire 4 — Beatriz Achterberg · Spreadsheet · playful, cautious 97 98 Function: retention windows and the deletion-job contract (S5), plus the pragmatist hat in the three-hats run. Distinct from Desmond Okafor's Spreadsheet function in 01 (auditing evidence behind claims): Beatriz sets numbers that become policy rather than checking numbers someone else asserted. 99 100 > Persona paragraph for `../CASTING.md` §4 (131 words): 101 > 102 > Dry, genial, and faintly delighted when a question she was told was qualitative turns out to have a number hiding in it. Decides by building the smallest table that forces the choice into the open — one row per case, one column per thing that must be true — and refuses to move until every cell is filled or explicitly marked unknown. Pushes back on "reasonable," "appropriate," and "as needed" wherever those words stand in for a duration, and on retention policies with no named mechanism to enforce them. Seven years in data governance at an insurance group writing the retention schedules the auditors read, then three on a privacy-engineering team building deletion pipelines. Blind spot: she measures only what is measurable, and will quietly drop what resists a column. Disagrees by showing you the empty cell. 103 104 ## 3. Debate plan 105 106 Patterns are `../CASTING.md` §5 protocols; every run ends in a file under `05-data-ops/decisions/` with the ⟨02⟩ footer recording model per seat and hire turns consumed. 107 108 **Three hats — the multi-DB abstraction (S3).** Three parallel ≤300-word positions on per-driver implementations vs. dialect branches vs. codegen (sqlc): 109 - *Optimist* — Anders: the case for the cleanest structural answer, codegen included. 110 - *Pessimist* — Yusuf, substituting for the Veteran seat (both Veterans are 03's; Yusuf's failure-mode instinct fits): where each option breaks at the boundary, generated-code review burden, dialect drift. 111 - *Pragmatist* — Beatriz: what each costs in files, review surface and time for a take-home whose SQLite backend is explicitly not a production peer. 112 113 I synthesize and rule. 3 hire turns + my synthesis. Justified because it is the structural choice 03 builds against and codegen has never actually been argued here, only name-checked. 114 115 **Adversarial pair — the `Search()` query shape (S2).** Anders proposes the `ProfileQuery` / result shape; Yusuf writes numbered objections, each ending with the "what would change my mind" line ⟨02⟩ requires; Anders answers every objection in writing and may decline to revise, but never silently; I rule. Yusuf holds the Designated Skeptic seat, which is at 2× org-wide and unavailable — his numbered-objection habit is the part of that archetype this pattern actually needs. 3 hire turns + my ruling. 116 117 **Newcomer's question — the 05→03 contract (S6).** Saoirse reads the drafted S1 cold and lists what she could not act on without asking me; I fix or record why not. The Newcomer archetype is at 2× org-wide (Wesley Okonkwo, 04, currently on loan to 01), so Saoirse plays the seat — with one contingency: if her pass comes back thin, I will ask team-lead for a short Wesley loan for a second cold read, since the value of this pattern comes from genuine unfamiliarity and a hire who has read this track's docs is only a partial proxy. 1–2 hire turns. 118 119 **Rotating devil's advocate — two single-turn objections.** Beatriz objects to the migration approach (S4) before it goes to 04; Saoirse objects to the retention windows (S5) before they go into `pii-governance.md`. Cheapest pattern in the catalog; the seat rotates so neither becomes "the negative one." 120 121 **Explicitly not debated — and why:** 122 - *DAO CRUD semantics beyond `Search`* — `Get`/`Create`/`Delete` are settled by the assignment and by the existing doc; the catalog names DAO CRUD as red/blue waste and it is three-hats waste too. 123 - *No red team / blue team in this track at all.* The catalog reserves it for the connector token lifecycle, which is 02's. Attack-tree work against this schema would duplicate `../02-ai-security-architecture/threat-model.md` and cross an ownership line. 124 - *The address field names* (`street_address`, `locality`, `region`, `postal_code`, `country`) — fixe … truncated (20 KB cap)
{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/profiles/saoirse-byrne/saoirse-byrne.md"
}1 --- 2 schema_version: 1 3 name: "Saoirse Byrne" 4 slug: "saoirse-byrne" 5 image: "profiles/saoirse-byrne/saoirse-byrne.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: "Saoirse Byrne, a warm animated woman in her early 30s with curly auburn hair loose to the shoulders, light freckles, wearing a mustard-yellow open collared shirt, bright engaged half-smile, 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 # Saoirse Byrne 23 24  25 26 27 ## Personality 28 29 Warm, quick, and cheerfully unbothered by being the least formal person in a schema review; narrates a design as the journey of one record — created here, read there, deleted when. Decides by walking the path end to end and noticing where the story skips a step, which is usually where the bug is. Pushes back on documents that are correct but unfollowable, and on any governance rule stated so abstractly that nobody could tell whether they had broken it. Five years writing developer documentation and SDK guides at an API company, three as a solutions engineer who watched customers misread her own docs in real time. Reads SQL comfortably and writes Go badly enough to be an honest proxy for a confused implementer. Blind spot: charming beats correct — the clearer story is not always the true one. Disagrees by retelling it. 30 31
{
"command": "python3 /Users/warrenthompson/.claude/skills/profile-gen/scripts/show_profile.py --profile /Users/warrenthompson/Source/LoginID/05-data-ops/profiles/saoirse-byrne/saoirse-byrne.md --root /Users/warrenthompson/Source/LoginID/05-data-ops 2>&1 | tail -5",
"description": "Display persona portrait in pane"
}{"name": "Saoirse Byrne", "mode": "hud", "hud": true, "badge": false, "background": false, "inline": false}{
"to": "priya-nandakumar",
"message": "Saoirse here — onboarded, portrait up, and deliberately half-ignorant on your instructions.\n\nSeats confirmed: (1) cold reader on S6, the newcomer's-question pass over the Go-shaped 05→03 contract in `multi-db-strategy.md`; (2) devil's advocate writing the single objection turn against the proposed retention windows in S5 before they land in `pii-governance.md`. Output is a written position back to you in the message, ≤300–600 words; you write the artifact under `decisions/`, not me.\n\nConfirmed unopened: `multi-db-strategy.md`, `pii-governance.md`, `research-data-ops-best-practices.md`. I've read only the root CLAUDE.md, this track's CLAUDE.md, your PLAN.md, and my own persona. I know S1's *intentions* from PLAN.md §1 — which is a small contamination, so I'll report questions against the contract as written rather than against what I know you meant it to say. That gap is the interesting part.\n\nWhen you hand me the contract I'll walk one record end to end and stop wherever the story skips: a profile created, read, searched, updated, deleted. Specifically I'll be hunting for — what exactly do I pass in, and what comes back when there's nothing there; which fields I'm allowed to omit and what happens if I omit them; which of your surprises (Update-creates, cascade-delete, the SQLite search gap) a reader hits *before* they hit the sentence warning them; whether every named type is defined somewhere in the doc or assumed; and whether `dao.Repository` and your three interfaces are reconciled in a way I could type without guessing. I'll list each one as \"I could not act here without asking you,\" not as prose.\n\nFair warning on rule 3: if it's correct and unfollowable, I'll say so plainly, and my job is to be wrong about it out loud rather than quiet about it. Ready when you are."
}<tool_use_error>Error: No such tool available: SendMessage. SendMessage is disabled for this session, in subagents as well as here.</tool_use_error>
{
"command": "grep -n \"^#\\{1,4\\} \" /Users/warrenthompson/Source/LoginID/05-data-ops/multi-db-strategy.md | head -60; echo ---; wc -l /Users/warrenthompson/Source/LoginID/05-data-ops/multi-db-strategy.md",
"description": "List headings and length of strategy doc"
}1:# Multi-Database Abstraction Strategy
5:## The interface contract (prose, not code)
22:## Why not one implementation with dialect branches
30:## Dialect differences that survive into the DAO layer
41:## Schema shape (prose, not DDL)
53:## Migration approach
60:## What the engineering track should take from this
69:# The Go-shaped contract (S1) — the 05→03 hand-off
77:## 1. The factory and the composite
96:## 2. Domain types
139:## 3. The interfaces
185:## 4. Error semantics
204:### The write-path retry seam — a decision, not an observation
212:## 5. Field-level schema
216:### `user_profile`
234:### `user_credential`
261:### `auth_method`
271:### Indexes
282:## 6. Out of scope for 03 — do not redesign; flag to 05 instead
297:## 7. The conformance suite — a requirement, not a suggestion
---
307 /Users/warrenthompson/Source/LoginID/05-data-ops/multi-db-strategy.md{
"file_path": "/Users/warrenthompson/Source/LoginID/05-data-ops/multi-db-strategy.md",
"offset": 69,
"limit": 239
}69 # The Go-shaped contract (S1) — the 05→03 hand-off
70
71 **Status: stable.** Stable means unlikely to be rewritten, not polished — per `../02-ai-security-architecture/planning-approach.md` §2, 03 should start against this rather than wait. Written to the project's hand-off standard: **03 should be able to implement this without re-deriving my reasoning.** Where a decision has a rationale 03 might otherwise re-litigate, the rationale is here; where something is deliberately out of 03's scope, §6 says so and says why.
72
73 Everything below is Go-*shaped* prose inside a markdown file. No `.go` or `.sql` files exist or should exist until Warren's explicit go-ahead.
74
75 Inputs that produced it: `decisions/search-query-shape.md` (S2), `decisions/multi-db-abstraction.md` (S3), `research-cockroachdb-postgres-semantics.md`, and a direct two-party agreement with 03 on the factory shape (below).
76
77 ## 1. The factory and the composite
78
79 Agreed directly with Renata Cole (03) on 2026-09-12. **Her `dao.New` signature is unchanged**; the composite is the resolution of what had been a silent mismatch between her single `Repository` return type and this track's three interfaces.
80
81 ```go
82 func New(driver, dsn string) (Repository, error) // driver: "postgres" | "cockroachdb" | "sqlite"
83
84 type Repository interface {
85 Profiles() ProfileRepository
86 Credentials() CredentialRepository
87 Methods() AuthMethodRepository
88 Close() error
89 }
90 ```
91
92 `Repository` stays an **interface**, not a struct with exported fields — Renata's reason, which is better than either I offered: an interface is mockable for her test strategy. The three accessors stay distinct rather than flattening into one interface because that separation is a governance control (`pii-governance.md`), not a modelling preference: a call site must reach for the credential accessor deliberately rather than find profile and credential methods side by side on one value.
93
94 `"postgres"` and `"cockroachdb"` both select the `postgres` package. They are distinct driver strings rather than one because the retry seam in §4 needs to know which engine it is talking to.
95
96 ## 2. Domain types
97
98 ```go
99 type UserProfile struct {
100 ID string // UUID
101 Name string // required, never empty
102 Phone *string // nil if unknown; E.164 when present
103 StreetAddress *string
104 Locality *string
105 Region *string
106 PostalCode *string
107 Country *string // ISO 3166-1 alpha-2, upper-case
108 Source string // "direct" | "idp_cache"
109 CreatedAt time.Time
110 UpdatedAt time.Time
111 }
112
113 type UserCredential struct {
114 ID string // UUID
115 UserID string // FK -> user_profile.id
116 Username string // unique, case-folded
117 MethodID string // FK -> auth_method.id
118 Secret []byte // nil unless SecretState == SecretSet
119 SecretState string // "none" | "set" | "revoked"
120 HashAlgo *string // non-nil iff SecretState == "set"
121 HashCost *int // non-nil iff SecretState == "set"
122 CreatedAt time.Time
123 UpdatedAt time.Time
124 }
125
126 type AuthMethod struct {
127 ID string
128 Name string // "password", "passkey", "otp", "oidc", ...
129 RequiresSecret bool
130 IsActive bool
131 CreatedAt time.Time
132 }
133 ```
134
135 **Pointers mean "absent", never "empty".** No field in this contract uses the empty string to mean absent. A `*string` that is non-nil and empty is invalid input, not a value — see §5.
136
137 **`SecretState` is new and is the fix for a real defect.** The previous prose said `secret` was "nullable, salted hash only" and stopped. Yusuf Karadag's review found that at least three distinct states were collapsing into one null — the method requires no server-held secret (passkey/WebAuthn, where the private key is client-side), the secret is not yet set, or the secret was revoked — and that `auth_method.requires_secret` was consequently load-bearing on nothing. An explicit state column makes the three distinguishable and gives `requires_secret` something to be checked against.
138
139 ## 3. The interfaces
140
141 ```go
142 type ProfileRepository interface {
143 Get(ctx context.Context, id string) (*UserProfile, error)
144 Search(ctx context.Context, q ProfileQuery) (results []UserProfile, total int, err error)
145 Create(ctx context.Context, p *UserProfile) (*UserProfile, error)
146 Update(ctx context.Context, p *UserProfile) (*UserProfile, error)
147 Upsert(ctx context.Context, p *UserProfile) (*UserProfile, error)
148 Delete(ctx context.Context, id string) error
149 }
150
151 type CredentialRepository interface {
152 GetByUsername(ctx context.Context, username string) (*UserCredential, error)
153 ListByUserID(ctx context.Context, userID string) ([]UserCredential, error)
154 Create(ctx context.Context, c *UserCredential) (*UserCredential, error)
155 Update(ctx context.Context, c *UserCredential) (*UserCredential, error)
156 Delete(ctx context.Context, id string) error
157 }
158
159 type AuthMethodRepository interface {
160 Get(ctx context.Context, id string) (*AuthMethod, error)
161 GetByName(ctx context.Context, name string) (*AuthMethod, error)
162 List(ctx context.Context, activeOnly bool) ([]AuthMethod, error)
163 }
164
165 type ProfileQuery struct {
166 Name *string // case-folded substring match; caller input escaped by the DAO
167 Phone *string // exact match on the E.164-normalized value
168 Region *string // case-folded exact match
169 Country *string // exact match, alpha-2, upper-cased by the DAO
170 Limit int // 1..100; 0 means "use default 50", deliberately
171 Offset int // 0..10_000
172 }
173 ```
174
175 `CredentialRepository` never accepts or returns a `UserProfile` field, in either direction, including as an internal convenience struct. That is §6.1.
176
177 **`Create` / `Update` / `Upsert` are three distinct methods, and this changes the earlier prose.** The previous version of this document described `Update` as "implemented as a portable upsert", which would mean `Update` on a missing row silently *creates* it. That is exactly the kind of surprise that produces a bug in a caller, so it is ruled out:
178
179 - `Create` — insert. `ErrAlreadyExists` if the ID is taken.
180 - `Update` — update an existing row by ID. **`ErrNotFound` if the row does not exist.** It never creates.
181 - `Upsert` — the portable `INSERT ... ON CONFLICT (id) DO UPDATE` form. Exists because the IDP hydration path genuinely does not know whether the row exists, which is a real caller rather than a hypothetical one. Any other caller should use `Create` or `Update`.
182
183 `Context` is first on every method and cancellation is honoured. **No method holds a transaction across calls.** If the service layer needs a multi-statement transaction, that is a change to this contract — flag it to 05 rather than improvising a `Begin`/`Commit` pair inside 03.
184
185 ## 4. Error semantics
186
187 ```go
188 var (
189 ErrNotFound = errors.New("dao: not found")
190 ErrAlreadyExists = errors.New("dao: already exists")
191 ErrDuplicateUsername = errors.New("dao: duplicate username")
192 ErrInvalidMethod = errors.New("dao: unknown or inactive auth method")
193 ErrInvalidQuery = errors.New("dao: invalid query")
194 ErrInvalidCredential = errors.New("dao: credential violates secret-state rules")
195 )
196 ```
197
198 Every backend returns the same sentinel for the same condition; the service layer never distinguishes `sql.ErrNoRows` from a SQLite-specific miss. Test with `errors.Is`. Anything not in this set is wrapped with `%w` and passed through.
199
200 **Translation is by SQLSTATE code only — never by matching error message text or constraint names.** `23505` → `ErrDuplicateUsername` or `ErrAlreadyExists` depending on the constrained column; `23503` → `ErrInvalidMethod`. This is not stylistic: `research-cockroachdb-postgres-semantics.md` §3 records that CockroachDB has a documented history of SQLSTATE inconsistency on constraint errors, and that message and `DETAIL` structure are not portable between the engines. String-matching an error message is fragile against one engine and reckless against two. Pin the CockroachDB target version.
201
202 **`ErrInvalidQuery` triggers**, enumerated so 03 does not have to infer them: a non-nil filter that is empty after trimming; `Limit < 0` or `> 100`; `Offset < 0` or `> 10_000`; `Country` not exactly two characters.
203
204 ### The write-path retry seam — a decision, not an observation
205
206 CockroachDB defaults to SERIALIZABLE and returns client-retryable `40001` in situations PostgreSQL at READ COMMITTED never produces, and its internal retry cannot help once results have begun streaming. `Upsert`'s `ON CONFLICT DO UPDATE` under contention is directly exposed to this.
207
208 **Ruling: every write goes through a `crdb.ExecuteTx`-style retry wrapper, and the CockroachDB backend stays at its default SERIALIZABLE.** The alternative — running CockroachDB at READ COMMITTED to drop the requirement — was considered and rejected: CockroachDB's READ COMMITTED is documented as *stronger* than PostgreSQL's, so choosing it to "simplify" would introduce a second engine-behaviour divergence hiding behind a matching level name. That is the same error, one layer down, that this phase has spent its time removing. The wrapper is near-zero cost on PostgreSQL and load-bearing on CockroachDB.
209
210 `Search` is read-only, two independent statements, no explicit transaction — it never encounters `40001` and needs no wrapper.
211
212 ## 5. Field-level schema
213
214 Nullability is the most expensive thing here to get wrong, which is why it carries its own column and its own reviewer.
215
216 ### `user_profile`
217
218 | Column | Go type | Postgres / CockroachDB | SQLite | Null? | Constraint / note |
219 |---|---|---|---|---|---|
220 | `id` | `string` | `UUID DEFAULT gen_random_uuid()` | `TEXT` | NOT NULL | PK. App-generated in Go before insert on SQLite; server-generated elsewhere. Callers never see the difference. |
221 | `name` | `string` | `TEXT COLLATE "C"` | `TEXT COLLATE BINARY` | NOT NULL | `CHECK (length(trim(name)) > 0)`. Collation named explicitly — see §6.3. |
222 | `phone` | `*string` | `TEXT` | `TEXT` | NULL ok | E.164-normalized at write. `CHECK (phone IS NULL OR phone ~ '^\+[1-9][0-9]{1,14}$')`; SQLite uses a `GLOB` equivalent. |
223 | `street_address` | `*string` | `TEXT` | `TEXT` | NULL ok | `CHECK (x IS NULL OR length(trim(x)) > 0)` — NULL or a real value, never `''`. |
224 | `locality` | `*string` | `TEXT` | `TEXT` | NULL ok | as above |
225 | `region` | `*string` | `TEXT` | `TEXT` | NULL ok | as above |
226 | `postal_code` | `*string` | `TEXT` | `TEXT` | NULL ok | as above |
227 | `country` | `*string` | `TEXT` | `TEXT` | NULL ok | `CHECK (country IS NULL OR (country = upper(country) AND length(country) = 2))` |
228 | `source` | `string` | `TEXT` | `TEXT` | NOT NULL | `CHECK (source IN ('direct','idp_cache'))`. Governs retention — `pii-governance.md`. |
229 | `created_at` | `time.Time` | `TIMESTAMPTZ` | `TEXT` (RFC3339 UTC) | NOT NULL | |
230 | `updated_at` | `time.Time` | `TIMESTAMPTZ` | `TEXT` (RFC3339 UTC) | NOT NULL | Touched on every write, including IDP re-hydration. Retention depends on this — `pii-governance.md`. |
231
232 **All five address columns are nullable, deliberately.** Partial addresses from `/identity` are the normal case for `idp_cache` rows, not an edge case. The `CHECK` forbidding empty strings is what keeps "absent" single-valued: NULL is absent, `''` is invalid, and no code has to decide which of two representations it is looking at.
233
234 ### `user_credential`
235
236 | Column | Go type | Postgres / CockroachDB | SQLite | Null? | Constraint / note |
237 |---|---|---|---|---|---|
238 | `id` | `string` | `UUID` | `TEXT` | NOT NULL | PK |
239 | `user_id` | `string` | `UUID` | `TEXT` | NOT NULL | FK → `user_profile(id)` **ON DELETE CASCADE**. See §6.2. |
240 | `username` | `string` | `TEXT` | `TEXT` | NOT NULL | **UNIQUE on `lower(username)`**, not on the raw column. |
241 | `method_id` | `string` | `UUID` | `TEXT` | NOT NULL | FK → `auth_method(id)`, RESTRICT |
242 | `secret` | `[]byte` | `BYTEA` | `BLOB` | NULL ok | Salted hash only, never plaintext. |
243 | `secret_state` | `string` | `TEXT` | `TEXT` | NOT NULL | `CHECK (secret_state IN ('none','set','revoked'))` |
244 | `hash_algo` | `*string` | `TEXT` | `TEXT` | NULL ok | 02 owns the value set. |
245 | `hash_cost` | `*int` | `INTEGER` | `INTEGER` | NULL ok | Stored so work factors can be raised later without a schema change (NIST SP 800-63B). |
246 | `created_at` | `time.Time` | `TIMESTAMPTZ` | `TEXT` | NOT NULL | |
247 | `updated_at` | `time.Time` | `TIMESTAMPTZ` | `TEXT` | NOT NULL | |
248
249 **Table-level `CHECK` tying the secret columns together:**
250 ```
251 CHECK (
252 (secret_state = 'set' AND secret IS NOT NULL AND hash_algo IS NOT NULL AND hash_cost IS NOT NULL)
253 OR
254 (secret_state IN ('none','revoked') AND secret IS NULL AND hash_algo IS NULL AND hash_cost IS NULL)
255 )
256 ```
257 This is what makes a row with a secret and no algorithm, or an algorithm and no secret, unrepresentable rather than merely discouraged.
258
259 **No PII columns exist on this table and none may be added.** §6.1.
260
261 ### `auth_method`
262
263 | Column | Go type | Both families | Null? | Note |
264 |---|---|---|---|---|
265 | `id` | `string` | UUID / TEXT | NOT NULL | PK |
266 | `name` | `string` | TEXT | NOT NULL | UNIQUE. Seeded with `'password'`. |
267 | `requires_secret` | `bool` | BOOLEAN / INTEGER 0-1 | NOT NULL | `false` for passkey/WebAuthn-style methods |
268 | `is_active` | `bool` | BOOLEAN / INTEGER 0-1 | NOT NULL | |
269 | `created_at` | `time.Time` | TIMESTAMPTZ / TEXT | NOT NULL | |
270
271 ### Indexes
272
273 | Index | Where | Serves |
274 |---|---|---|
275 | `GIN (LOWER(name) gin_trgm_ops)` | Postgres + CockroachDB only | `Search` name filter. **Expression index, not the bare column** — a GIN index on bare `name` is not used by a `LOWER(name)` predicate, so the accelerator would accelerate nothing. The predicate must match the index expression textually: `LOWER(name) LIKE ?` is the only permitted form, and a future `name ILIKE ?` silently loses the index. |
276 | B-Tree on `phone` | all | `Search` phone filter (exact/prefix on normalized values) |
277 | UNIQUE on `lower(username)` | all | auth lookup; case-folded uniqueness |
278 | B-Tree on `user_credential(user_id)` | all | `ListByUserID`, cascade delete |
279 | B-Tree on `user_credential(method_id)` | all | FK integrity |
280 | *(none)* | — | **`Search` ordering has no supporting index.** GIN cannot serve `ORDER BY`, so every `Search` sorts its filtered set. Accepted at this scale; recorded because a guarantee we are choosing *not* to make still gets written down. |
281
282 ## 6. Out of scope for 03 — do not redesign; flag to 05 instead
283
284 Numbered so there is no ambiguity. Each names **the enforcement site that makes it hold**, per the principle adopted in `decisions/search-query-shape.md`: *a guarantee with no named enforcement site is not a guarantee, it is a hope.*
285
286 1. **PII/credential separation.** `user_credential` carries no name, phone or address; `user_profile` carries no secret. This is a governance control, not normalization convenience — a breach of one table must not expose the other class of data (`pii-governance.md`). *Enforcement: the column sets in §5, the three-accessor split in §1, and a conformance test asserting neither struct gains a field from the other.*
287 2. **Cascade delete from profile to credential, and its asymmetry.** Deleting a profile destroys its credentials; the account ceases to exist. **The reverse does not hold** — deleting a credential leaves the profile and its PII standing on the `direct` retention clock. Deliberate. If "removing your last credential deletes the account" is wanted, that is a product policy decision, not a schema one, and it is currently *not* what the schema does. *Enforcement: `ON DELETE CASCADE` on the FK; the asymmetry is stated in `pii-governance.md`.*
288 3. **Case, collation and folding.** `ORDER BY LOWER(name) COLLATE "C" ASC, id ASC`, fixed and not caller-configurable, with collation named in DDL as well — `COLLATE "C"` on Postgres, `BINARY` on SQLite. Without this, page 1 of a search returns *different rows* per backend, because Postgres sorts by database collation while SQLite's default byte order puts every uppercase letter before every lowercase one. Cost accepted and named: `C` is byte order, so non-English names sort in an order a human would call wrong — identical-across-backends beats locale-pretty for a system whose whole premise is one contract over three engines. *Enforcement: the DDL collation clause, the `ORDER BY` expression, and a conformance test asserting identical ordering across backends.*
289 4. **`LIKE` escaping.** The DAO escapes `%`, `_` and `\` in caller input and emits an explicit `ESCAPE '\'` on both backends — Postgres has a default backslash escape, SQLite has none unless given. Without this, a search for `50%` matches every row. *Enforcement: the DAO's query construction, plus a conformance test using a term containing `%`.*
290 5. **`Country` normalization at write, not only at read.** Upper-casing the query without upper-casing the table makes a stored `us` silently invisible to a search for `US`. SQLite's `upper()` is ASCII-only — sufficient for alpha-2 codes, written down so nobody assumes it generalizes. *Enforcement: the `CHECK` constraint in §5 plus write-side normalization in both implementations.*
291 6. **UUID primary keys on every backend.** Required for CockroachDB (sequential keys create write hotspots on a single range), supported on Postgres, app-generated on SQLite. Settled by sourced research; do not reopen. *Enforcement: the column types in §5.*
292 7. **`auth_method` as a lookup table, not a native enum.** Adding a method is an `INSERT`, not a migration and not a deploy. *Enforcement: the FK from `user_credential.method_id`; see `migration-approach.md` §4.*
293 8. **The `ON CONFLICT ... DO UPDATE` upsert form.** The one syntax all three backends accept; CockroachDB's `UPSERT` shorthand is deliberately unused to avoid a second dialect. *Enforcement: `Upsert`'s single query shape.*
294 9. **The two-package split** (`postgres` serving Postgres + CockroachDB, `sqlite` separate) and the engine-aware retry seam inside the former. *Enforcement: package boundaries; `decisions/multi-db-abstraction.md`.*
295 10. **The `requires_secret` ↔ `secret_state` relationship.** A credential whose method has `requires_secret = false` must not carry `secret_state = 'set'`. **This one cannot be a DDL constraint** — `CHECK` cannot reference another table — so its enforcement site is DAO-layer validation returning `ErrInvalidCredential`, plus a conformance test. Named honestly as the weakest site in this contract, because the discipline is worthless if it only records the cases where a clean site existed.
296
297 ## 7. The conformance suite — a requirement, not a suggestion
298
299 **One suite, written once against the interface, executed against every backend implementation.** Not per-package unit tests: the same assertions, run twice, so a behavioural difference fails a test rather than reaching a user.
300
301 This is the condition the whole per-driver ruling rests on (`decisions/multi-db-abstraction.md`). Two implementations behind one interface guarantee both
… truncated (20 KB cap){
"command": "python3 /Users/warrenthompson/.claude/skills/profile-gen/scripts/show_profile.py --profile /Users/warrenthompson/Source/LoginID/05-data-ops/profiles/saoirse-byrne/saoirse-byrne.md --root /Users/warrenthompson/Source/LoginID/05-data-ops",
"description": "Re-display persona overlay"
}{"name": "Saoirse Byrne", "mode": "hud", "hud": true, "badge": false, "background": false, "inline": false}