Compare commits

..

57 Commits

Author SHA1 Message Date
coreci-hep 68800815c4 R1 deploy leg - pipeline-native host refresh on release (REQ-v1-003) (#9)
CI / node (push) Successful in 50s
CI / python (push) Successful in 4m37s
R1 deploy leg (REQ-v1-003 machinery set c18619; hep APPROVE c18623; vehicle call c18634). The merge IS the approval.

Reviewed from seat (head 67b806fc): additive only - .gitea/workflows/release.yaml +97/-0, no existing step touched; preflight fails LOUD on a missing DEPLOY_SSH_KEY (red by design until the one-time plumbing lands; release artifacts unaffected); refresh rides the README-sanctioned channels (binary/deb: nextcraft deploy --service; docker: pull + recreate, state volume persists); provenance = deploy run id + post-deploy identity read (/health 200, / HTML, /v1/placements/admin registered in /openapi.json); footer stays retired (c18624).

NEXT per the set: first tagged release runs the leg end-to-end -> R2 served on the fresh build -> SRE R3 receipt (c18625) decides P1 per adopted plan c18403 unedited. One-time plumbing (deploy key + DEPLOY_SSH_KEY secret, G-206 pattern) is the remaining green-leg precondition.

---ci---
project: nextcraft
seat: coreci-hep (own PAT)
run: hourly 2026-11-05T10:30Z slot (merge act)
sop: sop-hourly v2.0
2026-11-05 10:31:43 +00:00
coreci-nextcraft-po 67b806fc5c Update .gitea/workflows/release.yaml
CI / node (push) Successful in 51s
CI / python (push) Successful in 5m2s
2026-11-05 10:03:46 +00:00
jonathan b0d2069b15 Merge pull request 'docs: mark the v0.11 traceability matrix complete (shipped as v0.10.6)' (#8) from chore/roadmap-v0.11-traceability-complete into main
Release / release (push) Successful in 3m50s
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
Reviewed-on: #8
2026-11-02 13:24:30 +00:00
coreci-nextcraft-po ca704a55a0 docs(traceability): mark the v0.11 traceability rows complete — header + REQ-11-001..005 cells (shipped as v0.10.6, 2026-10-22)
CI / node (push) Successful in 50s
CI / python (push) Successful in 4m37s
2026-11-01 05:03:08 +00:00
jonathan 2f6b1e23ec Merge pull request 'docs(roadmap): mark the v0.11 phase list complete (shipped as v0.10.6)' (#7) from chore/roadmap-v0.11-status-complete into main
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
Reviewed-on: #7
2026-10-27 22:29:22 +00:00
coreci-nextcraft-po b4fb7b3881 docs(roadmap): mark the v0.11 phase list complete — phase-table header + phase 0 status (shipped as v0.10.6)
CI / node (push) Successful in 41s
CI / python (push) Successful in 3m34s
The v0.11 phase-list header still read "active" and phase 0's Status cell
still read "active" after the milestone shipped, so the repo's own record
contradicted itself: the Overview read COMPLETE, phases 1-6 read complete,
and the table two lines below them read active. The ship commit 5bc899eef3
flipped the Overview entry and the P6 verdict row and left the phase table
behind — a reader of the phase table alone sees a live v0.11 on a line
whose next milestone is not scoped yet.

Evidence (read at source, 2026-10-27):
- .ciagent/ROADMAP.md Overview: "Milestone v0.11 — COMPLETE (shipped as
  v0.10.6, 2026-10-22)".
- .ciagent/REQUIREMENTS.md header: "v0.11 Requirements (Complete — Placement
  Tracking and Audit, Feature; shipped as v0.10.6)".
- Tags v0.10.0 (8684d9d60c) .. v0.10.6 (85ddf5ea22) exist; release v0.10.6
  published 2026-10-22T04:41:47Z.
- main tip f4a35afbc9 — "chore(P06): clear checkpoint — milestone v0.11
  complete".

Changed: 2 lines, 3 cells, status markers only.
- "### v0.11 (active)" -> "### v0.11 (complete — shipped as v0.10.6,
  2026-10-22)"; the sibling headers read "### v0.8 (complete)" and
  "## Phase List (v0.10 — complete)".
- Phase 0 Status "active" -> "complete (v0.10.0)" — P0 -> v0.10.0 on the
  milestone's own tag line; the v0.10 table's phase 0 reads
  "complete (v0.9.0)".
- Phase 0 success-criteria cell gets the same "complete (shipped as
  v0.10.0)" marker the v0.10 table's phase 0 carries.

Blast radius: documentation only, one file (.ciagent/ROADMAP.md). No code,
no dependency, no config, no shipped artifact, no requirement or phase
touched. The milestone's scope is not rewritten — the cells now say what
the tag, the release and git history already say.

Never silently rewritten: every flip rides this auditable commit and the
merge request that carries it.
2026-10-27 05:37:19 +00:00
coreci-nextcraft-po f4a35afbc9 chore(P06): clear checkpoint — milestone v0.11 complete
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
Milestone v0.11 (Placement Tracking and Audit) complete — shipped as
v0.10.6 (the v0.10.x final = the milestone release). All phases
verified + audited (P6 review SHIP, 0 P0s; D-112 audit PASS); merged
to main via PR #6; v0.10.5 (the deferred per-phase tag) + v0.10.6
(release lane on the tags). The next milestone opens at SPECIFY per
the D-062 rails.
2026-10-22 04:41:41 +00:00
coreci-nextcraft-po 5bc899eef3 docs(milestone): complete placement-tracking — ROADMAP v0.11 COMPLETE (shipped as v0.10.6) + the P6 review/audit verdict row
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
Overview entry flipped to COMPLETE (shipped as v0.10.6, 2026-10-22);
the P6 row recorded with the final-review verdict (SHIP, 0 P0s, the two
P1 doc-level findings dispositioned) + the audit PASS + the ship facts.
Never silently rewritten: every flip rides this auditable commit.
2026-10-22 04:41:36 +00:00
coreci-nextcraft-po 0c51ddc4df docs(milestone): complete placement-tracking — v0.11 COMPLETE, shipped as v0.10.6
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
The v0.11 milestone marked complete in REQUIREMENTS.md (the v0.10.x
final patch = the milestone release per the tag line). P5 learner
placement visibility + P6 final review SHIP (0 P0s, 2 P1 doc-level
findings dispositioned) + audit PASS; merged to main 85ddf5ea (PR #6).

---ci---
phase: 6
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-001, REQ-11-002, REQ-11-003, REQ-11-004, REQ-11-005]
  partial: []
---/ci---
2026-10-22 04:41:29 +00:00
coreci-nextcraft-po 85ddf5ea22 Merge pull request 'merge: milestone/v0.11-placement-tracking -> main (v0.11 complete — Placement Tracking and Audit)' (#6) from milestone/v0.11-placement-tracking into main
CI / node (push) Successful in 42s
Release / release (push) Successful in 2m47s
CI / python (push) Successful in 3m34s
v0.11 milestone complete — Placement Tracking and Audit (REQ-F-020; REQ-11-001..005). P6 final review SHIP (0 P0s; 2 P1 doc-level findings dispositioned) + audit PASS. Ship: tag v0.10.6 at the merge, release lane, branch cleanup.
2026-10-22 04:41:12 +00:00
coreci-nextcraft-po ca08f93eeb chore(P06): final review SHIP (0 P0s) + audit PASS — ship_pending
CI / node (push) Successful in 43s
CI / python (push) Successful in 3m38s
Wave 6-1 multi-persona review (lead-developer): D-100 one-transaction
discipline VERIFIED (transition_application = the sole post-creation
status writer, R-78 — marketplace store carries no status write; one
Session, one commit covering both tables + the application row); R-80
re-hire coherence VERIFIED (rehire = fresh PlacementRow + placement.rehired,
never a fabricated application transition; partial-unique guard, the
IntegrityError surfaces); G-319 pin split VERIFIED (own-postings list
counts-only, test_postings_read.py negative PII assertion unchanged);
D-110 wire pins VERIFIED (placement.ts mirrors placements/base.py
field-for-field + status-union, pinned in pins.test.ts); D-092 census
VERIFIED (the overview mock block replaced by real placements; talent
mocks retained + honestly labeled; no fake-converted surface). Zero P0s.

Security-auditor pass: no credentials/identity PII on any log or asset
path (learner_id an opaque handle everywhere, R-79); gate orders pinned
per principal class (employer session->claim->entitlement; admin token;
learner allowlist->verified-18+->data); the ledger no-delete path intact
(R-70); tamper/fork/concurrency arms green; export determinism + the
G-321 methodology honesty re-verified; no secret echoed by any
exception path. Clean.

P1 findings, dispositioned with recorded follow-ups (never loop back
to EXECUTE): (1) PLAN Must-Haves names tests/placements/test_e2e_audit.py
but the D-111 E2E proof lives as tests/placements/test_export.py::
test_e2e_audit_proof — doc-path drift, substance verified green; (2) the
D-112 addition census omits employers/store.py L64-70 (the two-line
_AUDIT_EVENT_KINDS "application" vocabulary addition, P2 REQ-11-002
audit lane — in-scope, the census text incomplete).

Wave 6-2 audit (D-112 reconstruction): 27 branch commits vs the
.census — 18 added + 23 changed files, all placement-surface-scoped
(+ the .ciagent process set, README/deploy doc rows); feat commits
scoped to the placement surface; the two same-cycle CI red triages +
the disclosed probe.txt pair on main accounted. PASS.

---ci---
phase: 6
milestone: v0.11
status: ship_pending
---/ci---
2026-10-22 04:40:51 +00:00
coreci-nextcraft-po 5b66c75917 chore(P05): phase 5 complete — checkpoint advanced
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
---ci---
phase: 5
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-005]
  partial: []
---/ci---
2026-10-22 04:40:39 +00:00
coreci-nextcraft-po 8fd229641a chore(P05): phase 5 complete — v0.10.5 shipped (the deferred per-phase tag landed at the P6 ship at e32e54e7aa, release lane fired on it)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
REQ-11-005 complete: the mine point-read (G-322) + the learner status
read (D-104) + withdraw on the unchanged D-043 stack; the job-detail
status panel; A-304 badge honesty. CI #168 green both lanes after two
same-cycle red triages. REQ-11-005 row flipped complete (v0.10.5) in the
companion commit (its default title: Update REQUIREMENTS.md — contents
API message field, disclosed).

---ci---
phase: 5
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-005]
  partial: []
---/ci---
2026-10-22 04:40:34 +00:00
coreci-nextcraft-po c2b907b544 Update .ciagent/REQUIREMENTS.md
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-22 04:40:03 +00:00
coreci-nextcraft-po 376dc3d59a docs(roadmap): v0.11 P5 landed 2026-10-21 — full vertical slice at e32e54e7aa, CI run #168 green both lanes after two same-cycle red triages; the v0.10.5 per-phase tag defers to the P6 ship lane per the recorded next-release posture (flagged here, never silent)
CI / node (push) Successful in 41s
CI / python (push) Successful in 3m32s
2026-10-21 04:51:11 +00:00
coreci-nextcraft-po e32e54e7aa fix(test): bound the G-328 placement-surface slice at the withdraw gate (the verify-CTA copy is a different, sanctioned surface)
CI / node (push) Successful in 40s
Release / release (push) Successful in 2m57s
CI / python (push) Successful in 4m3s
2026-10-21 04:46:50 +00:00
coreci-nextcraft-po 6b451d4de7 fix(test): scope the no-persistence pin to setItem (the board's sessionStorage handoff is a sanctioned read)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:46:44 +00:00
coreci-nextcraft-po 7e16b33ff6 fix(web): reword the withdraw client docstring (the G-312 pin greps the word 'retry' to EOF from the employer-auth section)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:46:43 +00:00
coreci-nextcraft-po 922219748e fix(api): drop the unused type-ignores on the P5 response constructions (CI #167 mypy)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:46:37 +00:00
coreci-nextcraft-po 38b3e07cfe fix(test): type the EngineError predicates in the learner-status web tests (CI #166 node-lane red triage — the QS dynamic import is untyped, so instanceof alone narrows nothing; the engine-client-identity assert.ok + InstanceType cast pattern)
CI / node (push) Failing after 32s
CI / python (push) Failing after 47s
2026-10-21 04:43:52 +00:00
coreci-nextcraft-po b489c59980 feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [10/10] — wave 5-2: fetch-stub wire tests + composition source pins
CI / node (push) Failing after 30s
CI / python (push) Has been cancelled
Plan: .ciagent/PLAN.md Phase 5 (waves 5-1/5-2); manager verdict nextcraft#1 (hep, 2026-10-19T19:29Z). P5 vertical slice per the binding wave order: store reads -> routes -> web wiring -> tests in the same wave.
2026-10-21 04:41:27 +00:00
coreci-nextcraft-po f34959026a feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [9/10] — wave 5-2: wire pins + the A-304 badge-honesty census (mock talent surfaces stay placement-grep-clean)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:41:21 +00:00
coreci-nextcraft-po 38d8e2c4b9 feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [8/10] — wave 5-2: job-detail application-status panel — mine-read first, the seven D-099 states, timestamped history, placement confirmation, withdraw CTA with honest 409/403 copy (A-304 badge honesty)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:41:20 +00:00
coreci-nextcraft-po a46ecf142a feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [7/10] — wave 5-2: getMyApplication / getLearnerApplicationStatus / withdrawApplication clients (no new error classes)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:41:15 +00:00
coreci-nextcraft-po 809ce3bda8 feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [6/10] — wave 5-2: placement.ts mirrors for the learner status reads (D-110)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:41:14 +00:00
coreci-nextcraft-po c25e42c005 feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [5/10] — wave 5-1: gate + scoped-events + hired + revoked-null + mine arms + reads-stay-free tests
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:41:08 +00:00
coreci-nextcraft-po a62acf8241 feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [4/10] — wave 5-1: GET /applications/mine (G-322) + GET /applications/{id} (D-104 status + history + placement) on the D-043 stack
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:41:08 +00:00
coreci-nextcraft-po e1ef420b2d feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [3/10] — wave 5-1: get_placement_by_application active-placement read (G-316 shape)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:41:02 +00:00
coreci-nextcraft-po 51a030fa60 feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [2/10] — wave 5-1: get_application_by_posting_learner mine point-read (G-322)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:41:01 +00:00
coreci-nextcraft-po f5dcb12a93 feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [1/10] — wave 5-1: learner status-read contracts (LearnerApplicationSummary / LearnerApplicationStatusResponse / MineApplicationResponse)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:40:55 +00:00
coreci-nextcraft-po e090be2f8d chore: remove probe artifact (empty file, created by an API capability probe this cycle — disclosed in nextcraft#1)
CI / node (push) Successful in 40s
CI / python (push) Successful in 3m9s
2026-10-21 04:40:28 +00:00
coreci-nextcraft-po 27aaa313b4 Add probe.txt
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-21 04:40:12 +00:00
coreci-nextcraft-po 9759a0e21e fix(test): de-calendar employers auth wall-clock pin (cherry of CI #149 red-triage fix from the main lane)
CI / node (push) Successful in 41s
CI / python (push) Successful in 3m25s
2026-10-19 21:25:45 +00:00
coreci-nextcraft-po fe9ece1cc9 Merge pull request 'comms: TL;DR prologues (nextcraft#2) + CI #149 red-triage test fix' (#3) from comms/tldr-prologues-v1 into main
CI / node (push) Successful in 40s
CI / python (push) Successful in 3m7s
2026-10-19 21:25:34 +00:00
coreci-nextcraft-po bad56988b7 docs(comms): TL;DR prologue for deploy/README (comms-etiquette v1.0, nextcraft#2)
CI / node (push) Successful in 41s
CI / python (push) Successful in 3m2s
2026-10-19 21:22:09 +00:00
coreci-nextcraft-po e5855ac800 docs(comms): TL;DR prologue for README (comms-etiquette v1.0, nextcraft#2)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-19 21:22:03 +00:00
coreci-nextcraft-po f4f6d8b9c9 fix(test): de-calendar employers auth wall-clock pin (CI #149 red triage)
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
2026-10-19 21:21:56 +00:00
CIAgent 61139c61bf chore(P04): phase 4 complete — v0.10.4 shipped, release 959
CI / node (push) Successful in 41s
CI / python (push) Successful in 3m23s
---ci---
phase: 4
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-004]
  partial: []
---/ci---
2026-10-01 14:14:52 +00:00
CIAgent 9f727427a7 feat(P04): complete audit-export phase (REQ-11-004)
CI / node (push) Successful in 41s
Release / release (push) Successful in 2m49s
CI / python (push) Successful in 3m36s
Phase 4 complete: the self-contained CIRR-style audit export (JSON +
markdown summary from the same document, methodology with the three
pilot gaps + the G-321 chain-honesty statements, G-325 revoked
evidence bundles, R-73 determinism), the admin placements block +
export links, the full-chain E2E audit proof, the security review
fixes. Verify four-layers PASS. Ships as v0.10.4.

---ci---
phase: 4
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-004]
  partial: []
---/ci---
2026-10-01 14:14:38 +00:00
CIAgent 151a2ffff8 chore(P03): phase 3 complete — v0.10.3 shipped, release 958
CI / node (push) Failing after 46s
CI / python (push) Successful in 3m19s
---ci---
phase: 3
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-003]
  partial: []
---/ci---
2026-10-01 14:00:24 +00:00
CIAgent cb3af9d06a feat(P03): complete placement-reporting phase (REQ-11-003)
CI / node (push) Successful in 39s
Release / release (push) Successful in 2m52s
CI / python (push) Successful in 3m34s
Phase 3 complete: the D-107 overview (distinct-learner counting
toward 1,000), the per-employer placement view, placement.ts wire
pins, the engine-client placements section, the /employer/applications
page + the overview mock-block conversion. Verify four-layers PASS.
Ships as v0.10.3.

---ci---
phase: 3
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-003]
  partial: []
---/ci---
2026-10-01 13:59:56 +00:00
CIAgent 89bff0c36a chore(P02): phase 2 complete — v0.10.2 shipped, release 957
CI / node (push) Successful in 41s
CI / python (push) Successful in 3m17s
---ci---
phase: 2
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-002]
  partial: []
---/ci---
2026-10-01 13:12:42 +00:00
CIAgent 89e21eb4b2 feat(P02): complete application-state-machine phase (REQ-11-002)
CI / node (push) Successful in 40s
Release / release (push) Successful in 2m46s
CI / python (push) Successful in 3m28s
Phase 2 complete: the employer per-target transition routes (G-311
gate order), the learner withdraw (D-043 stack unchanged, G-323
apply-terminal guard), the operator lane (fail-closed, G-327 422/409
split, the R-80 re-hire with G-326 stamping), the G-319 pin split,
33 route tests + the security-review pins. Verify four-layers PASS.
Ships as v0.10.2.

---ci---
phase: 2
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-002]
  partial: []
---/ci---
2026-10-01 13:12:26 +00:00
CIAgent 3332a7f264 chore(P01): phase 1 complete — v0.10.1 shipped, release 956
CI / node (push) Successful in 42s
CI / python (push) Successful in 3m4s
---ci---
phase: 1
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-001]
  partial: []
---/ci---
2026-10-01 12:39:06 +00:00
CIAgent 6b0b20cee1 feat(P01): complete placement-store-ledger phase (REQ-11-001)
CI / node (push) Successful in 40s
Release / release (push) Successful in 2m49s
CI / python (push) Successful in 3m14s
Phase 1 complete: the 9th D-027 store (PlacementRow partial-unique
active guard + PlacementEventRow global sha256 hash chain with the
UNIQUE(prev_hash) fork backstop), the D-099 matrix + event
vocabulary, the ONE transactional transition_application (D-100,
flush-then-hash-then-commit), main.py/deps wiring, the 22-test
adversarial suite (tamper/fork/concurrency/no-delete-path), the
security review fixes (tz conversion, actor vocabulary, conftest
topology), verify four-layers PASS. Ships as v0.10.1.

---ci---
phase: 1
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-001]
  partial: []
---/ci---
2026-10-01 12:38:48 +00:00
CIAgent 0c5fe12d0a chore(P00): phase 0 complete — checkpoint complete stage (v0.10.0 shipped, release 955)
CI / node (push) Successful in 42s
CI / python (push) Successful in 2m58s
---ci---
phase: 0
milestone: v0.11
status: complete
---/ci---
2026-10-01 11:47:59 +00:00
CIAgent 8684d9d60c docs(P00): complete pre-execution phase — v0.11 placement tracking
CI / node (push) Successful in 40s
Release / release (push) Successful in 2m50s
CI / python (push) Successful in 3m12s
Phase 0 complete: SPECIFY (D-098 scope lock — REQ-F-020 activated
as REQ-11-001..005), CLARIFY (D-099..D-112), RESEARCH (R-67..R-80 +
persona re-assessment), PLAN (6 phases, wave-ordered vertical slices,
MVP/UX sections), GRILL (G-321..G-328 applied, verdict PROCEED at
0.82), MVP/UX check (all 3 sections verified). Tags on the v0.10.x
line; phase 0 ships as v0.10.0.

---ci---
phase: 0
milestone: v0.11
status: complete
---/ci---
2026-10-01 11:47:33 +00:00
CIAgent 7f7d39b748 chore(P06): clear checkpoint — milestone v0.10 complete
CI / node (push) Successful in 44s
CI / python (push) Failing after 3m18s
Milestone v0.10 (B2B Employer Network) complete — shipped
as v0.9.6 (release 954). All phases verified + audited; merged to
main 579fc6b. The next milestone SPECIFY re-establishes the
checkpoint.

---ci---
phase: 6
milestone: v0.10
status: complete
---/ci---
2026-10-01 01:07:13 +00:00
CIAgent e3978d4df2 docs(milestone): complete b2b-employer-network
The v0.10 milestone marked complete in the milestone
docs: REQUIREMENTS.md header → Complete — shipped as v0.9.6 (the
v0.9.x final patch = the milestone release per the tag-line model),
intro rewritten with the shipped-on-v0.9.x wording + all-5-complete
summary mirroring the v0.9 section; ROADMAP.md overview line →
COMPLETE (shipped as v0.9.6, 2026-10-01) and the v0.10 phase-list
phase 6 row → complete (v0.9.6, release 954) with the final
review/audit verdicts. The config.json milestone block stays v0.10;
the next milestone SPECIFY re-establishes it.

---ci---
phase: 6
milestone: v0.10
status: complete
requirements:
  covered: [REQ-10-001, REQ-10-002, REQ-10-003, REQ-10-004, REQ-10-005]
  partial: []
---/ci---
2026-10-01 01:06:18 +00:00
CIAgent 579fc6b105 merge: milestone/v0.10-b2b-employer-network -> main (v0.10 complete — B2B Employer Network)
Release / release (push) Successful in 2m44s
CI / python (push) Successful in 3m1s
CI / node (push) Successful in 40s
The v0.10 milestone: employers become principals of the marketplace
(REQ-F-019 activated as REQ-10-001..005 per D-085). Employer auth
with scrypt password hashing, hashed session tokens, and login/
register throttles (G-311/G-312/G-315); employer identity as claim/
verify over the v0.6 marketplace entities with advisory evidence
adjudication and admin approval (D-089/D-089/G-316/G-317); the
dashboard converted to real data with honest empty/pending/
processing states (no mock charts); self-serve postings through
the shared D-091 entitlement gate byte-identical with the admin
path (G-318/G-319); self-serve checkout over the v0.9 catalog
through the REAL webhook path with the own-session mock-complete
closing the G-301 class (G-302/G-303/G-304 carried forward); and
the admin network overview — accounts, claim queue, and audit in
one panel (D-094). Final review verdict SHIP: 2 P0s found and
fixed (the /me claim-state leak, the dead billing confirm button),
3 P1s dispositioned; audit PASS — reconstruction clean, discipline
green. Tags v0.9.0..v0.9.6 on the v0.9.x line; the tag lane builds
every release's full asset matrix per D-064.

---ci---
phase: 6
milestone: v0.10
status: complete
requirements:
  covered: [REQ-10-001, REQ-10-002, REQ-10-003, REQ-10-004, REQ-10-005]
  partial: []
---/ci---
2026-10-01 01:05:45 +00:00
CIAgent c2158af6d3 chore(P06): clear checkpoint — milestone v0.9 complete
Release / release (push) Successful in 2m50s
CI / python (push) Successful in 2m8s
CI / node (push) Successful in 41s
---ci---
phase: 6
milestone: v0.9
status: complete
---/ci---
2026-09-30 11:03:38 +00:00
CIAgent 0592c9993d merge: milestone/v0.9-payment-engine -> main (v0.9 complete — Payment Engine)
The v0.9 milestone: payment processing and subscription management
(REQ-F-018 activated as REQ-9-001..005 per D-074). The billing module
(ai_service/billing/ — PaymentProvider protocol with deterministic
mock + Stripe-compatible REST over the shared pool, the 7th D-027
store with 7 tables, the HMAC-SHA256 webhook core per D-077), the
seeded catalog + /v1/billing routes, the pricing page's runtime
conversion, posting entitlement enforcement with honest 402 upgrade
CTAs, and the full subscription lifecycle with a queryable audit
trail. Operator-mediated checkout at pilot (D-076); grants exactly-
once per session (G-303); webhook state resolved from local rows only
(G-302); consumption as a single conditional UPDATE (G-304). Tags
v0.8.0..v0.8.6 on the v0.8.x line; the tag lane built every release's
full asset matrix + registry image (D-064).

---ci---
phase: 6
milestone: v0.9
status: complete
requirements:
  covered: [REQ-9-001, REQ-9-002, REQ-9-003, REQ-9-004, REQ-9-005]
  partial: []
---/ci---
2026-09-30 11:03:33 +00:00
CIAgent f831839a90 decision(D-073): founder credential policy — retain, store securely, no rotation
CI / node (push) Successful in 50s
CI / python (push) Successful in 1m45s
CT 113 root password + the org runner registration token are retained for
future use per founder directive (rotation declined; supersedes the G-206
rotation request). Both live only in .ciagent/.env.secrets (gitignored,
0600 — verified the sole copies tree-wide). The live-host deploy key
(nc_ci_deploy -> root@10.0.0.105) is persistent per founder approval.
Release notes updated to match.

---ci---
phase: post-milestone
milestone: v0.8
status: complete
decision: D-073
---/ci---
2026-09-29 16:52:39 +00:00
CIAgent f0ad84fe74 chore(P06): clear checkpoint — milestone v0.8 complete
CI / node (push) Successful in 47s
CI / python (push) Successful in 1m44s
---ci---
phase: 6
milestone: v0.8
status: complete
---/ci---
2026-09-29 16:12:13 +00:00
CIAgent 1e6d035d6d docs(milestone): complete ship-anywhere
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled
---ci---
phase: 6
milestone: v0.8
status: complete
---/ci---
2026-09-29 16:12:09 +00:00
CIAgent 1a88ab3199 merge: milestone/v0.8-ship-anywhere -> main (v0.8 complete — Ship Anywhere)
Release / release (push) Successful in 3m45s
CI / python (push) Successful in 1m52s
CI / node (push) Successful in 1m0s
The v0.8 milestone: installable on any Debian-based server via docker
(Gitea registry image, honest sandbox degrade), deb (amd64+arm64,
advisory postinst), and arch-aware binary — with release consistency
enforced by pre-commit hooks + Gitea Actions (3-runner fleet on CT 113,
machine-made releases with the full 8-asset matrix + registry image), the
CLI carrying the full suite (guide, channel-aware doctor), and the admin
runbook (deploy/README.md, evidence-backed recipes).

---ci---
phase: 6
milestone: v0.8
status: complete
requirements:
  covered: [REQ-8-001, REQ-8-002, REQ-8-003, REQ-8-004, REQ-8-005]
  partial: []
---/ci---
2026-09-29 16:04:40 +00:00
CIAgent d35695e5fa chore(P06): clear checkpoint — milestone v0.7 complete
---ci---
phase: 6
milestone: v0.7
status: complete
requirements:
  covered: [REQ-7-001, REQ-7-002, REQ-7-003, REQ-7-004, REQ-7-005]
  partial: []
---/ci---
2026-09-27 10:30:12 +00:00
301 changed files with 31482 additions and 1540 deletions
+68 -1
View File
@@ -43,6 +43,73 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
49. **D-052 tooling conclusions** — coverage: pytest-cov (Python) + c8 wrapped around existing `tsx --test` (no runner swap, no vitest); markers `fast`/`real_server`/`userns` under `--strict-markers`, unmarked defaults to fast, fast run = `-m "not real_server and not userns"`; pytest-timeout `signal` method, 60s global / 180s real-server+userns via collection hook (D-054); CLI fresh-clone E2E env-gated (`CLI_E2E=1`) into `cli:test:e2e` with a 900s budget (D-055); `filterwarnings = ["error", <narrow module+message-scoped upstream ignores>]` (D-056); mypy relaxed-first baseline (`check_untyped_defs`, `warn_unused_ignores`, no full strict) (D-058); recharts 2→3 verified by tsc + static build + existing tests (D-059); shared Python helpers in `tests/helpers/` package, fixtures stay in conftest (D-060); pyproject.toml is the single version source, `FastAPI(version=)` reads package metadata (D-061).
50. **D-062 v0.7 wave order = dependency-ordered: wiring before composition before elimination before hardening** — P1 harness wiring (coverage/timeout/markers/turbo tasks/leak fixes) MUST precede P2 suite composition because the cut/consolidate decisions are evidence-based (P1's coverage data tells P2 what to cut); P3 warning elimination is independent of P1/P2 but ordered after them to avoid rebasing `session.exec()` conversions over moved test files; P4 lint hygiene (ESLint + noqa RUF100) after P3 so the eslint-disable resolutions happen under a warning-clean tree; P5 type+dependency hardening last (mypy adoption and recharts 3 are the riskiest changes — they run against a fully-stabilized harness, and recharts gets the fresh coverage/ESLint net as its safety net); P6 final review. ESLint adoption (D-048) rides P4 because the orphaned eslint-disable comments only become meaningful once a config exists.
### v0.9 Payment Engine Architecture (Phase 0 RESEARCH — Payment Engine, D-075..D-084 binding)
**Module:** `ai_service/billing/` (7th domain module, D-031 pattern — extend ai-service, no new apps). Boundary rule (family discipline): `billing/` never imports `agents/` or `api/`; it imports `config` + its own `base.py` only. Files: `base.py` (PaymentProvider protocol + pydantic contracts + normalized event names), `store.py` (7th D-027 SQLite store), `mock.py` (deterministic provider, Stripe-shaped ids/signatures per R-36/R-48), `stripe.py` (Stripe-compatible REST impl — form-encoded bodies, Basic auth, Idempotency-Key per R-33/R-49), `factory.py` (env-gated selection, UnknownPaymentProviderError → G-11 loud mock fallback), `catalog.py` (seeded catalog constant + idempotent seed).
**Provider protocol (D-075):** `create_checkout_session(spec) -> CheckoutSession`, `retrieve_session(id)`, `cancel_subscription(id, *, at_period_end)`, `update_subscription(id, new_price_ref, ...)` (resolves subscription_item id inside the provider — R-47 dual-charge guard), `verify_webhook(raw_body: bytes, signature_header: str, *, now=None) -> VerifiedEvent` (HMAC-SHA256 over `"{t}.{raw_body}"`, compare_digest, all-v1 iteration for secret rolls, ±300s replay window, ignore non-v1 schemes — R-38..R-40), `normalize_event(provider_event) -> CanonicalBillingEvent` (R-43 mapping).
**Store (D-080, 7 tables):** `packages` (one-time: posts_limit, price_cents, duration_days), `plans` (recurring: interval, price_cents), `purchases` (posts_limit/posts_used/expires_at snapshot + price snapshot — D-078), `subscriptions` (state machine: trialing/active/past_due/cancelled/expired — G-305: no `upgraded` status, upgrade is a plan_id transition within `active` recorded as a billing_event; expires_at, provider ref), `subscription_events` (provider event log, verbatim ids, UniqueConstraint event_id — dedupe), `checkout_sessions` (client_reference_id reconciliation), `billing_events` (full audit: provider events + manual admin actions with provenance). Same SQLite file (`Settings().db_path`), WAL pragmas, Protocol + SQLiteBillingStore pair, main.py lifespan wiring with state-injection override.
**Routes:** `api/billing.py` (`/v1/billing/catalog` public GET; `/v1/billing/webhook` public POST — raw-body-first HMAC verify, then parse, then idempotent process, 200-fast) + `api/billing_admin.py` (`/v1/billing/admin/*` — require_admin dep verbatim R-51: checkout on behalf of employer (D-076), entitlement view, manual grant/revoke, event log query) + mock-only confirm route (D-082 as amended by G-301: mounted only when provider=mock AND composed with require_admin — no unauthenticated completion route on a payment surface). Entitlement enforcement composes into `create_posting` AFTER require_admin: billing store's `consume_posting(employer_id)` → domain error → route translates to `HTTPException(402, detail={reason, posts_used, posts_limit, upgrade_cta})` (dict-detail precedent = require_verified_age).
**Settings (R-53):** `payment_provider: str = "mock"`, `payment_api_key: SecretStr`, `webhook_secret: str = ""` (unset → per-boot random → webhooks fail closed — correct for a payment surface). Env: `AI_PAYMENT_PROVIDER`, `AI_PAYMENT_API_KEY`, `AI_WEBHOOK_SECRET`.
**Web (REQ-9-002):** pricing page → server shell (metadata retained, A-411/pins) + `PricingClient` component fetching `/v1/billing/catalog` (A-406 board-client pattern, AbortController + honest loading/error/empty states); `engine-client.ts` gains `listBillingCatalog()` + `PaymentRequiredError` (402 dict-detail discrimination); types in `packages/types/billing.ts` dual-schema-pinned in pins.test.ts. Admin entitlement view: moderation surface gains per-employer entitlement state (client fetch, same pattern).
**Money (D-081):** integer cents end-to-end; pydantic `int ge=0`; TS `number` documented as cents; currency `usd` fixed.
### v0.10 B2B Employer Network Architecture (Phase 0 RESEARCH — B2B Employer Network, D-086..D-097 binding)
**Module:** `ai_service/employers/` (8th domain module, D-031 pattern — extend ai-service, no new apps). Boundary rule (family discipline): `employers/` never imports `agents/` or `api/`; it imports `config` + its own files only. Files: `base.py` (pydantic contracts: EmployerAccount, EmployerClaim, session/token response shapes — dual-schema-pinned against packages/types/employer.ts, D-093; PostingSummary reused from marketplace.ts, never duplicated), `store.py` (8th D-027 SQLite store, 4 tables, D-095), `passwords.py` (stdlib scrypt KDF core — the billing/webhooks.py crypto-core precedent; D-086 params N=2^14/r=8/p=1/dklen=32 as module constants pinned by test, self-describing `scrypt$16384$8$1$<salt_b64>$<hash_b64>` rows, `hmac.compare_digest` verify; per-password `secrets.token_bytes(16)` salt; zero new deps), `claim_evidence.py` (stdlib registrable-domain normalization — urlsplit hostname + suffix match; the advisory match signal, D-089). HTTP surface in `api/employers.py` (`/v1/employers/*` — session-gated) + `api/employers_admin.py` (`/v1/employers/admin/*` — require_admin imported from marketplace_admin, the billing_admin.py:27 precedent, D-094). No service.py — routes compose the store via `deps.py` app.state DI (`get_employer_store`, the "routes never import stores directly" territory rule).
**Store (D-087/D-089/D-095, 4 tables on the shared `Settings().db_path`):** `employer_accounts` (id PK, email UNIQUE indexed, password_hash self-describing scrypt string, employer_id UNIQUE nullable-until-claim-approval, created_at), `employer_sessions` (id PK, token_hash UNIQUE — stores ONLY sha256(token), never the token; account_id indexed, created_at, expires_at = login + SESSION_TTL_DAYS), `employer_claims` (id, account_id, employer_id indexed, status pending|approved|rejected, evidence JSON `{account_email_domain, entity_website_domain, match}`, created_at, decided_by, decided_at), `employer_audit_events` (auto id PK, account_id/employer_id nullable indexed, kind/name, detail JSON, operator, created_at — the BillingEventRow audit shape). Mechanics mirror billing/store.py exactly (WAL / synchronous=NORMAL / busy_timeout / foreign_keys pragmas, `sa_type=String/JSON` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` tz re-labeling, Protocol + SQLiteEmployerStore pair, surfaced IntegrityError, check-first unique patterns — the insert_application precedent; G-304-style single-statement writes where concurrency matters). The `employer_` prefix is mandatory — bare `employer` is marketplace's EmployerRow table. `SQLModel.metadata.create_all` is idempotent: zero migration, the 4 tables join existing DBs at next boot (the proven billing-store compat).
**Session flow (D-087/D-090):** register (email+password, UNVERIFIED per D-097 — no learner gates, no email verification; scrypt hash via passwords.py) → login mints `secrets.token_urlsafe(32)`; the row stores `sha256(token)` + expires_at = now + 7-day module constant (lazy TTL at read — G-306, no timer); the client persists the opaque token in sessionStorage `nc-employer-session` (the G-308 adminToken wrapper pattern) and attaches `Authorization: Bearer <token>` on every fetch (CSRF-immune by construction — no ambient credential; D-039 static export has no Next middleware). The `require_employer_session` dependency (the require_admin extract→verify→return shape): **401** on missing/expired/unknown token (credential failure), **403** reserved for cross-entity authorization; returns session + account + claimed employer_id. logout DELETEs the row + audit event (revoke = lookup miss → 401); multiple concurrent sessions per account allowed. Token namespaces cannot collide: admin routes compare against the env token, employer routes hash-lookup.
**Auth throttling (D-088, G-5 in-memory pattern):** (a) per-EMAIL failed-login window — Settings knob, default 5 failures / rolling 15 min → 429, successful login clears the email's window; (b) global per-min cap on login+register attempts (the `_check_global_create_rate` deque) → 429. Keyed by SUBMITTED EMAIL, never client IP (behind HAProxy the proxy owns request.client.host). Both copy the proven deque/monotonic-window mechanics (identity _SubmitRateLimiter + sandboxes global cap); test fixtures reset the module state.
**Claim flow (D-089):** authenticated `POST /v1/employers/claims {employer_id}` → claim row status=pending with computed evidence (stdlib domain normalization; every seeded employer website is an example.com placeholder → match=false corpus-wide — the signal is advisory, A-304) → operator reviews in the admin queue → approve sets employer_accounts.employer_id (UNIQUE — one account : one entity) + decided_by/decided_at + audit event; reject frees the entity for a new claim. One PENDING claim per entity + one APPROVED owner per entity enforced check-first in the store. Auto-approval deferred: without email verification the match is forgeable — the operator decision activates.
**Entitlement composition (D-091) — one shared helper, two principal gates:**
```
admin arm: require_admin ──▶ consume_posting_entitlement(store, employer_id, admin-CTA) ──▶ insert
employer arm: require_employer_session ──▶ claim check (403) ──▶ same helper(session employer, employer-CTA) ──▶ insert
helper: store-missing → 503 fail-closed
NoActivePackage → 402 {reason: no_active_package, posts_used: 0, posts_limit: 0, upgrade_cta}
PackageExhausted → 402 {reason: posting_limit_reached, posts_used: 0, posts_limit: 0, upgrade_cta}
(posts_used/posts_limit hardcoded 0 in BOTH arms — the v0.9 wire contract, byte-identical;
UpgradeRequiredError consumes reason + upgrade_cta unchanged; gate runs BEFORE insert)
```
`consume_posting` (G-304 single conditional UPDATE) is untouched; the helper lives in api/ beside both callers (no billing/→api/ boundary violation).
**Employer checkout (D-091, transposes billing_admin.create_checkout):** `POST /v1/employers/billing/checkout {item_id}` — session-gated; unclaimed → 403 + claim-CTA; buyer = the session's claimed entity (supersedes D-076 operator mediation); catalog snapshot + unique-pending placeholder + CheckoutSessionSpec metadata employer_id=claimed entity, operator_ref→account provenance; success/cancel URLs from public_base_url (G-310); employer mock-complete arm mounted only under provider=mock, employer-session-gated, completing one's OWN session (403 cross-session), minting the signed event through the REAL webhook path (D-082 transposed). The v0.9 provider protocol/webhooks/store grants serve employer checkouts verbatim — only principal derivation changes.
**Web (D-092/D-093, engine-client employer section — no new client module):** `engine-client.ts` gains register/login/logout/me, claim, own-postings CRUD/publish/close, own-checkout, own-entitlement + a `SessionRequiredError` (401 discrimination — parseError today has no 401 arm; honest logged-out state, never a retry loop) + the `nc-employer-session` sessionStorage wrapper (the adminToken pattern; logout clears storage + calls revoke; AbortController everywhere). Conversion scope: postings page (own-postings lifecycle), overview page (real active-postings count + per-posting applicant counts from the applications table; talent-match/placement metrics stay mock-labeled), employer profile (claimed entity via the existing public GET), new /employer/login + /employer/register (greenfield — no login page exists in any route group); **talent search + candidate profile stay the v0.1 mock** (no candidate backend — D-092). Wire types in `packages/types/employer.ts` dual-schema-pinned in pins.test.ts (the billing.ts pin block template, D-093). Pricing CTAs convert from contact-path to employer-login path (supersedes D-079).
**Admin network overview (D-094):** the moderation page (app/(admin)/admin/moderation/page.tsx — where the v0.9 EmployerEntitlementPanel landed) gains sibling sections below it: employer accounts list (email, claimed entity, entitlement state), claim queue (evidence + domain-match signal, approve/reject), audit — fed by operator routes in api/employers_admin.py (require_admin + audit events): GET /v1/employers/admin/accounts, GET …/claims, POST …/claims/{id}/approve|reject, GET …/audit; web transport via the existing adminToken() G-308 wrapper + honest 403 locked states (the entitlement panel's locked-state pattern).
**Settings/wiring (D-095/R-60):** throttle knobs as Settings ints (the identity_submits_per_min shape) + .env.example entries beside AI_ADMIN_TOKEN; session TTL is a module constant (no env). main.py: employer_store lifespan wiring (the billing_store getattr-guard template, ~line 182-190) + close() in the finally block; employers + employers_admin routers mount beside the billing block (inline-import pattern); CORS `allow_headers` gains "Authorization" (main.py:341) with a tests/api/test_cors.py preflight pin — the E87 lesson applied before it ships this time. scrypt probe-verified in the shipped venv (3.11.2): ~50 ms/hash at D-086 params, default maxmem admits the 16 MiB working set.
### v0.8 Distribution & Release Architecture (Phase 0 RESEARCH — Ship Anywhere, D-063..D-072 binding)
**Runner fleet topology (CT 113, 10.0.0.113):** three org-level gitea-runner containers (official image `docker.io/gitea/runner:3` — the Docker Hub `gitea/act_runner` repo is deprecated, renamed `gitea/runner`) in **sibling-container mode**: each mounts the host `/var/run/docker.sock`, has its OWN `/data` volume holding the `.runner` registration file (identity + credentials; shared state = re-registration), registers once against `https://git.coreci.dev` (Gitea 1.26.1, probe-verified) with the founder-provided org-level registration token (chat-only, reset after fleet green). Hybrid labels via `GITEA_RUNNER_LABELS`: `node` → `docker://node:24-bookworm`, `python` → `docker://python:3.11-bookworm`, `release` → `docker://node:24-bookworm`, `ubuntu-latest` → `docker://docker.gitea.com/runner-images:ubuntu-latest` (label format `name[:schema[:args]]`; a job's `runs-on` matches label names, the `docker://` image selects the job container). Per-runner `runner.capacity: 1` (default; also caps per-job docker subnet consumption) → fleet concurrency 3. Cache: each runner's built-in cache server must be pinned to a job-reachable `cache.host` + fixed published port (dockerized runners otherwise create per-job networks the cache address is unreachable in — `actions/cache` fails ETIMEDOUT); 32 GB disk guarded by `cache.repo_size_limit`, `health_check.min_free_disk_space_mb`, and a host-side `docker system prune` cron. Workspace-path alignment inside/outside the runner container is NOT needed for our jobs (no `container.bind_workdir`; `docker build` TARS the context over the API) — documented as the known sibling-mode gotcha if a future workflow bind-mounts.
**Workflow lanes (`.gitea/workflows/`):** push lane on `on: push` (all branches) — node lane (label `node`): corepack pnpm 12.3.4 → `pnpm install --frozen-lockfile` → eslint + tsc via `pnpm --filter` over the 5 TS workspaces (never wholesale `turbo lint`, which pulls the venv-dependent ai-service task) → `pnpm web:test` + `pnpm cli:test`; python lane (label `python`): `scripts/bootstrap.sh` venv → `scripts/lint.sh` (ruff) → `scripts/typecheck.sh` (mypy) → `FAST=1 scripts/test.sh` (fast subset `-m "not real_server and not userns"` per D-063 — real_server/userns NEVER in job containers; docker's default seccomp blocks nested `unshare --user` there anyway); ubuntu-latest lane: workflow_dispatch hello-world only (fleet verification, REQ-8-001). Tag lane on `on: push: tags: ['v*']` is the EXCLUSIVE owner of release assets + registry push (D-064): builds from the tag checkout (jobs never have `dist/`), stamps `build:binary` with `gitea.ref_name`, attaches binaries + debs + checksums via the same REST API scripts/release-assets.sh uses (`GET /releases/tags/{tag}` → `POST /releases/{id}/assets`), never `actions/upload-artifact`. Secrets: `REGISTRY_TOKEN` + `GITEA_TOKEN` as org/repo Actions secrets (self-serve `PUT /api/v1/repos/…/actions/secrets/{name}` first, founder UI fallback); the automatic per-job `GITEA_TOKEN` CANNOT publish packages (upstream-confirmed) — the PAT secret is the only viable registry credential. pnpm/pip caches via `actions/cache@v4` (runner 3.x serves cache v2 + self-patches stock actions ≥4.2/4.4) once cache-host pinning is live; deterministic fallback = volume-mounted stores via `container.options` + `valid_volumes`.
**Image layers (multi-stage Dockerfile, `git.coreci.dev/coreci/nextcraft`, amd64 only per D-069):** deps/build stages — web export stage (`node:24-bookworm`, `NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build` → `apps/web/out`, ~16 MB; the URL is build-time-only, inlined by engine-base-url.ts) + venv stage (`python:3.11-bookworm`, `pip install` prod deps from pyproject — no dev extras); runtime stage `python:3.11-slim-bookworm` (~400–500 MB total with the venv ~130 MB + web export + the D-066 CLI-binary embed at `/usr/local/bin/nextcraft`, ~117 MB): non-root `USER nextcraft`, `AI_WEB_STATIC_DIR=/opt/nextcraft/web` (single-port StaticFiles mount, D-039), `NC_CONTAINER=1` entrypoint marker, `HEALTHCHECK CMD` urllib `GET /health` (slim has no curl), `VOLUME /home/nextcraft/.nextcraft` (state root `~/.nextcraft` = DB `data/nextcraft.db` + `sandboxes/`, env-overridable via `AI_DB_PATH`/`AI_SANDBOX_DIR` with `~` expansion — config.py:39/77/86-93), `CMD uvicorn ai_service.main:app --host 0.0.0.0 --port 8420`. Zero AI_* env needed at build; all real-provider keys stay optional at runtime (G-11 fallbacks). Unprivileged by default; the sandbox fabric honestly degrades inside docker (below).
**Deb/binary asset matrix (every release, tag-workflow-built):** `nextcraft-linux-x64` + `.sha256` (existing pipeline, build-binary.mjs SEA), `nextcraft-linux-arm64` + `.sha256` (cross-build: esbuild bundle is arch-agnostic; the ONLY arch-specific step is `copyFileSync(process.execPath, out)` — replaced by a copy from the official `node-v<ver>-linux-arm64.tar.xz` base, probe-verified 200/30 MB; postject adds an ELF note, pure file surgery, never runs the target — QEMU-free per D-69, optional binfmt `--version` smoke if binfmt is registered on CT 113), `nextcraft_<ver>_amd64.deb` / `nextcraft_<ver>_arm64.deb` + `.sha256` (`dpkg-deb --root-owner-group -Zxz`, never executes payload → cross-arch safe; control fields: Package nextcraft, Version = tag minus `v` (policy-clean), Architecture per build, empty Depends, `Recommends: nodejs (>= 18), python3 (>= 3.11), git, util-linux` — soft deps, D-067), binary at `/usr/bin/nextcraft`, postinst = advisory `nextcraft doctor` (never fails dpkg) + next-steps, NO systemd unit (`deploy --service` owns unit generation) + NO proxy fragments. install.sh arch-gate becomes arch-resolve (`x86_64`→x64, `aarch64|arm64`→arm64; sha256 + version integrity gates unchanged). Image tags per D-065: semver `<tag>` + `latest` on newest release, no per-push tags.
**Channel upgrade semantics (D-071):** docker = `docker pull git.coreci.dev/coreci/nextcraft:<tag>` + recreate the container (state persists in the named `nc-state` volume); `deploy`/`bootstrap`/`dev` refuse in-container (D-066 — container check before the repo-root checks, pointer to the docker paths; `doctor`/`verify`/`guide` remain allowed, `verify` gains a container-mode branch checking the mounted web dir + `/health` instead of a repo clone). deb = `dpkg -i` the new deb + `nextcraft deploy --service` (idempotent v0.4.6 rail). binary = re-run install.sh + `nextcraft deploy --service`. doctor detects the channel (NC_CONTAINER/`/.dockerenv` for docker; `dpkg -s`/`/usr/bin/nextcraft` for deb) and describes its upgrade path; `nextcraft guide` serves offline-first embedded man-page text channel-appropriately (D-072 — TS constants bundled by esbuild into the SEA binary, zero runtime fetches).
**Sandbox degrade in containers (D-068):** capability probe promoted verbatim from tests/helpers/userns.py into `ai_service/sandbox/capability.py` (`userns_available()` = `shutil.which("unshare")` guard + real `subprocess.run(["unshare","--user","--map-root-user","true"])`) — the truth source at boot + per-create; container markers (`/.dockerenv`, `NC_CONTAINER=1`, cgroup string) only flavor remediation text. Degrade: boot notice (the main.py:404 mount-less / main.py:202 admin-token G-11 warning pattern), learner sandbox create rejects honestly with 503 + remediation pointer (a new `except SandboxUnavailableError` arm beside the existing `PoolFullError→503` in api/sandboxes.py:157-160 — today it would 500), doctor surfaces the degraded capability via a real userns attempt (upgrading the `which unshare` check, doctor.ts:138-150); chat/agents/grading untouched. Supersedes A-101's cwd-jail fallback for deployed schools (never implemented — no code removal needed).
**Pre-commit rails (D-070):** ONE root `.pre-commit-config.yaml`, local hooks only (ruff on Python paths; eslint/tsc via workspace-scoped invocations), no network at hook run; missing toolchain = loud failure, never silent skip; CI is the authoritative backstop.
### Confirmed Technology Stack (v0.2)
| Technology | Version | Purpose |
@@ -301,4 +368,4 @@ v0.4 delivers distribution (CLI + binary releases); later milestones fill in the
- **No search → Semantic vector search (pgvector)** — Filter UI replaced with vector similarity search
- **No payments → Payment processing** — Pricing page replaced with real subscription/payment flows
The monorepo structure (apps/web + apps/ai-service + apps/cli + packages/*) accommodates further apps without restructuring.
The monorepo structure (apps/web + apps/ai-service + apps/cli + packages/*) accommodates further apps without restructuring.
-9
View File
@@ -1,9 +0,0 @@
{
"phase": 6,
"stage": "audit",
"milestone": "v0.7",
"milestone_name": "test-harness-review",
"phase_role": "final",
"attempts": 0,
"updated_at": "2026-09-27T07:05:00Z"
}
+231
View File
@@ -1,3 +1,48 @@
# Nextcraft v0.9 — GRILL.md (Adversarial Review Verdict)
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-BINDINGS (REVISE → record G-301..G-310, then SHIP) · **Confidence:** 0.85 · **Date:** 2026-09-29
## Summary
The v0.9 Payment Engine plan (5 requirement phases + final, D-074 locked) is feasible, correctly scoped, and hermetically testable — the operator-mediated checkout model is coherent end-to-end and entitlement needs no employer auth. But red-teaming against the working tree (not the plan's description of it) found 10 binding defects: a mock-complete route mounted on every default-config production box (D-082's "never on a real deployment" rationale is **false** — mock IS the default), webhook handlers that would trust forgeable event payloads (leaked secret = free entitlements), a check-then-increment race in consume_posting, event-id-only dedupe defeated by Stripe's dual invoice events, a phantom `upgraded` state with no transition rows, an un-pinned ingest bypass that would 402 the seeded corpus, a checkout route naming contradiction between REQUIREMENTS/ROADMAP and binding D-076, an admin panel requiring a browser token entry that exists nowhere in apps/web, and a stripe checkout that cannot create sessions (success_url never populated — Happy Path 5's "zero code changes" was false). Two plan citations proved wrong in-tree (the "sitemap env-gated routes" precedent does not exist; admin surface has zero admin-token plumbing). Two Gs bite inside P1's Task 1-1-02 — recording them now prevents store-schema retrofit churn after P1 ships. Nothing re-architects: every G amends an existing task, must-have, or requirement sentence; the phase/wave structure is untouched.
## Per-Axis Findings
| # | Axis | Verdict | Reason |
|---|------|---------|--------|
| 1 | Feasibility | PASS | Operator-mediated checkout coherent end-to-end; entitlement composes after require_admin with employer_id as body field; 6-phase shape matches the proven v0.6 cadence |
| 2 | Scope creep | CONCERN | REQ-9-003/ROADMAP name a public POST /v1/billing/checkout contradicting D-076 (G-307); admin panel hides a browser-token requirement (G-308); "stripe switch = zero code changes" false without success/cancel URLs (G-310) |
| 3 | Security | CONCERN | Mock-complete route unauthenticated on default deployments (G-301); webhook state resolution unspecified — payload trust = forged entitlements (G-302) |
| 4 | Correctness | CONCERN | consume_posting check-then-increment race (G-304); dual invoice events defeat event-id dedupe (G-303); `upgraded` state hole (G-305); out-of-order events against missing targets + grace timing unspecified (G-306) |
| 5 | Testability | PASS | Hermetic by construction: MockTransport byte-contracts, now-injection, direct-handler-call mock-complete — provided G-306 keeps grace lazy |
| 6 | Backward compat | PASS | Fixture strategy workable (~9 test sites grant unlimited to legacy seeded employers); pricing metadata pin survives conversion; ingest bypass unpinned → G-309 |
| 7 | Cost/benefit | PASS | D-074 stands: payments unblocks employer self-serve; no new deps (hand-rolled REST, no Stripe SDK); challenge declined |
| 8 | Plan-vs-reality | PASS (with corrections) | All cited patterns verified in-tree; two rationale citations false (G-301/G-308); suite count 674/625-fast drift noted |
## Binding Decisions (G-301..G-310 — applied to PLAN/REQUIREMENTS/ROADMAP before execute)
**G-301 — mock-complete route: admin-gate it; D-082's "never on a real deployment" rationale is false.** The default provider is mock (D-075) and the pilot IS a real deployment — `POST /v1/billing/mock/complete` mounted "only when provider=mock" is mounted on every default-config production box. An unauthenticated completion route on a payment surface is a hole no matter how unguessable `cs_mock_…` ids are. Decision: keep the provider=mock conditional mount AND compose `require_admin` on the route — the operator completing a session after out-of-band payment is exactly the honest pilot flow D-076 describes. Amends PLAN Task 3-2-03, MH-3c; D-082's rationale corrected (ship-notes record: the sitemap "env-gated routes" precedent does not exist — this is the first factory-conditional mount in-tree). Tests: no-token → 403 under mock; route 404s under stripe; E2E green with admin-gated complete. Confidence 0.85.
**G-302 — webhook events are untrusted ID references; state resolves from local rows.** If checkout.completed reads session status/amount from the event payload, a leaked AI_WEBHOOK_SECRET = free entitlements (forge status=complete, payment_status=paid for any session id). Decision: handlers treat event payloads as ID references only — the session resolves from the local checkout_sessions row (creatable only via the admin route); unknown session id → recorded as an `unmatched` billing_event, NO entitlement, 200. Purchase price/name from the stored snapshot (D-078), never the event body; renewal amounts from the local plan row. Amends Task 3-2-02. Tests: signed checkout.completed with no local row → 200, zero purchases, one unmatched event; inflated payload amount → purchase price = snapshot. Confidence 0.85.
**G-303 — grants are idempotent per session, not per event id.** R-43/D-083 accept invoice.paid AND invoice.payment_succeeded and Stripe's async path fires distinct completed events — different event ids for the same logical completion sail past event-id dedupe and double-grant. Decision: `purchases.checkout_session_id` gets a UniqueConstraint; subscription upsert keys on provider subscription id; entitlement creation is exactly-once per session regardless of event count. Amends Task 1-1-02 (schema), Task 3-2-02; extends MH-3b. Test: two signed checkout.completed events, different event ids, same session → exactly one purchase row (IntegrityError → created=False), one subscription. Confidence 0.85.
**G-304 — consume_posting is a single conditional UPDATE, not check-then-increment.** insert_application survives concurrency via UniqueConstraint; posts_used has no such backstop — two concurrent create_posting calls both read posts_used=9 and both increment. Decision: consume_posting = one statement `UPDATE purchases SET posts_used = posts_used + 1 WHERE id = <newest non-expired with remaining> AND posts_used < posts_limit` (rowcount-checked; 0 rows → PackageExhausted/NoActivePackage). Amends Task 1-1-02. Test: 1 post remaining, two concurrent consumes on separate connections (WAL) → exactly one succeeds. Confidence 0.80.
**G-305 — `upgraded` is not a status state.** D-083's "(+ upgraded)" has no transition-matrix rows and REQ-9-005's canonical states don't include it. Decision: upgrade = plan_id transition within `active` (provider update per R-47 with items[0][id] — pinned by MH-3d), recorded as a billing_event with provenance; `upgraded` removed from the status enum — D-083's "(+ upgraded)" reinterpreted as the auditable upgrade ACTION, not a state. Amends Task 5-1-01, MH-5a. Tests: upgrade on active → status stays active, plan_id changes, billing_event exists; stored-status set contains no `upgraded`. Confidence 0.75.
**G-306 — out-of-order tolerance includes missing targets; grace expiry is lazy, not a timer.** (a) invoice.payment_failed/invoice.paid can arrive before checkout.completed creates the local subscription — handlers never 500 on unknown targets: record as unmatched (G-302 marker), return 200; recovery is the grant or admin manual complement. (b) past_due → expired at 7 days is computed lazily from past_due_at + 7d at read/entitlement time — no background timer, keeping tests hermetic. Amends Task 3-2-02 (tolerance rule), Task 5-1-01 (grace semantics); risks 3-4. Tests: signed invoice.paid for unknown subscription → 200 recorded no crash; entitlement check with injected now at past_due_at+6d → granted, +8d → 402/expired; no sleeps. Confidence 0.80.
**G-307 — align REQ-9-003/ROADMAP route naming to D-076.** REQUIREMENTS REQ-9-003 and ROADMAP phase-3 say `POST /v1/billing/checkout`; binding D-076, PLAN, and ARCHITECTURE say `/v1/billing/admin/checkout` behind require_admin. Decision: amend the wording at this gate (the requirement text must not name a public un-gated checkout route that will not exist). Test: route-inventory pin — no /v1/billing/checkout route exists; /v1/billing/admin/checkout 403s without token. Confidence 0.90.
**G-308 — define the browser-side admin-token entry; the admin panel is otherwise unreachable.** The admin surface is mock-data driven; no X-Admin-Token exists in apps/web. REQ-9-004's "admin surface shows per-employer entitlement state" hides a new requirement: an operator token entry in the browser — Task 4-2-01's A-406 citation is wrong (A-406 is for public routes). Decision: a small token input in the entitlement panel persisted to sessionStorage, sent by a new adminFetch wrapper in engine-client (X-Admin-Token header); 403 renders an honest locked state with the token prompt; the token is never logged. Amends Task 4-2-01. Tests: stubbed 403 → locked-state render; fetch stub asserts the header; no admin-token string in non-test web source beyond the wrapper. Confidence 0.75.
**G-309 — pin the ingest bypass: seed ingestion is never entitlement-gated.** Enforcement composes into the admin create_posting route ONLY, but the seeded-corpus ingest upserts ~20 postings directly through the store and must never hit the 402 path. Decision: explicit rule + test — run_ingest (and any future adapter) bypasses consume_posting by design; enforcement exists only in the admin route. Amends Task 4-1-01 (test list), risk 5 companion. Test: seeded ingest on a store with zero purchases → 20 postings upserted, zero 402s, zero consumption rows. Confidence 0.90.
**G-310 — default success/cancel URLs for the stripe checkout, or delete the "env-only switch" claim.** R-34: success_url is required for hosted checkout; the admin checkout body is {item_id, employer_id} and no task populates it — the stripe path cannot create sessions as planned. Decision: the checkout route defaults success_url = {public_base_url}/marketplace/pricing?checkout=success and cancel_url = …?checkout=cancelled (public_base_url exists at config.py:170); loud G-11 boot warning when stripe is selected and public_base_url is unset. Amends Task 3-2-01 (defaults) + Task 3-1-01 (byte-contract asserts both fields). Tests: MockTransport byte-contract shows success_url/cancel_url present; factory-selection test shows the loud warning when stripe + empty origin. Confidence 0.80.
---
# Nextcraft v0.6 — GRILL.md (Adversarial Review Verdict)
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-BINDINGS · **Confidence:** 0.72 · **Date:** 2026-09-21
@@ -162,3 +207,189 @@ a-6 ack on dedup'd appends too (tight margin); a-7 MH-1d 3× runs = one-time shi
## Escalations
None. All four seams remain founder-locked via D-016 / REQ-5-001..007; every finding resolved at confidence ≥ 0.65.
---
# v0.8 GRILL (Phase 0, 2026-09-27) — Ship Anywhere
**Verdict: GO-WITH-BINDINGS · Confidence: 0.74** — G-201..G-210 binding (3×P0, 7×P1); applied to PLAN.md before P1 execution. (Inline orchestrator grill — three delegated griller runs returned empty; logged per Step 4 error recovery at full autonomy.)
**Evidence:** every load-bearing plan claim re-verified against RESEARCH.md's probe-backed census (R-1..R-32), the existing v0.4 pipeline code (build-binary.mjs, release-assets.sh, install.sh), and Gitea 1.26.1 live state. The research layer held; findings are interaction-surface and lifecycle gaps.
## Per-Axis Findings
| Axis | Verdict | Rationale |
|------|---------|-----------|
| Goal coverage | PASS | All four founder options map to REQ-8-001..005 with falsifiable MHs; docker-per-phase-upload honored by the release workflow design. |
| Feasibility | AT-RISK → fixed | QEMU-free arm64 SEA (R-25/26) probe-first is honest, but the plan pinned no failure exit; G-201 forces a decided fallback ladder. Runner fleet on 4GB is bounded by capacity=1 + label routing (R-4). |
| Vertical-slice integrity | PASS | P1 ships a green fleet + live pipelines; P2 ships a pullable image; P3 ships installable assets; P4 ships channel-aware CLI; P5 ships the runbook. Each independently real. |
| Wave ordering | AT-RISK → fixed | P2→P3 ordering claimed "forced" but both only extend P1's workflow; G-202 makes the true dependency explicit (asset-matrix ownership) and permits P2/P3 work within the same release lane without re-ordering phases. |
| Testability | AT-RISK → fixed | MH-1d asserted workflow-built release assets but the release job runs on the tag push — which exists only at ship time; G-203 defines the within-phase proof (temporary tag on the phase branch). MH-2b smoke gate had no in-container test harness; G-204 pins the concrete assertions. |
| Security posture | AT-RISK → fixed | Secrets chain mostly sound (R-6/R-13), but the REGISTRY_TOKEN self-serve PUT needs a value the .env token cannot mint — G-205 forces founder-side PAT creation with scoped package-write, closing a plan-time fantasy. CT root password rotation was "ask founder"; G-206 makes it a recorded P0 completion condition. |
| Performance/capacity | PASS | Image budget ~400-500MB (R-18) asserted in smoke gate; 32GB disk bounded by prune cron + cache caps + health_check floor (R-5). |
| Operational readiness | AT-RISK → fixed | The runbook (P5) would document a fleet whose bring-up lives only on CT 113; G-207 requires the compose/config/cron committed as fleet-as-code in-repo. Degrade remediation text must name the privileged variant precisely (G-208). |
| Dependency risk | PASS | No new product deps; docker.io engine on release lane accepted-risk with documented future fix; corepack fallback decided (R-11). |
| Adoption/UX | PASS | Three channels each end at doctor/verify green; guide offline-first with parity test; refusals honest. |
## Binding Decisions (applied to PLAN.md)
- **G-201 (P0)** — arm64 build has a decided fallback ladder: probe QEMU-free cross-arch SEA on the runner first; if the blob fails to run under emulation, fall back to binfmt+qemu for FULL arm64 builds (not just blob generation); if the runner can't install binfmt, ship v0.8 with x64-only debs/binaries and record the arm64 deferral — never leave the release lane red or blocked.
- **G-202 (P1)** — dependency truth: P2 and P3 both consume P1's release workflow as their extension point; P2 precedes P3 for founder-priority (docker is the headline channel), not technical force. PLAN wave-order paragraph corrected; no phase reorder.
- **G-203 (P1)** — release-workflow within-phase proof: MH-1d/D-3d prove via a temporary `v0.7.1-rc1`-style tag pushed to the phase branch head at verify time, workflow observed building + attaching to the (pre-release/draft or immediately-deleted) release, tag then deleted. The phase ship tag remains the real artifact.
- **G-204 (P1)** — smoke gate pinned: container starts → /health 200 within timeout → `GET /` content-type text/html AND contains a known marker (not just non-JSON-404) → degrade boot notice present in logs → fabric create returns 503 with remediation → clean stop. All five assertions in the workflow step; failure = red release job.
- **G-205 (P0)** — REGISTRY_TOKEN is a founder-minted PAT with package-write scope (the .env GITEA_TOKEN may lack it): P1's self-serve attempt becomes "PUT the secret IF the existing token can mint it — else escalate to founder with the exact scope needed (write:package + write:repository)". The plan must not assume the PUT succeeds; escalation path has a founder question, not a silent wait.
- **G-206 (P0)** — CT 113 root password rotation is a P0 completion condition of Wave 1-1: after deploy-key install, the founder is asked to rotate; if not confirmed by Wave 1-2, an `escalation: ct_root_password_still_live` commit lands and the ship notes carry it. Never silently carried.
- **G-207 (P1)** — fleet-as-code: the compose file, per-runner configs, and prune cron are committed in-repo (e.g. `deploy/ci/`) as the reproducible fleet definition; CT 113 deployment is a copy + `docker compose up -d` — the runbook's fleet recipe references the committed files, not box memory.
- **G-208 (P1)** — degrade remediation text must be exact: name the unprivileged default, the seccomp constraint, the documented privileged/seccomp-override variant (docker run flags), and the host-side alternative (run the stack binary-side); the boot notice and 503 payload carry the same text (single remediation constant, tested).
- **G-209 (P1)** — pre-commit hooks must not break contributor boxes lacking the toolchain silently in the other direction either: hooks fail-loud (D-070) but the README dev section documents the exact skip path (`pre-commit run --no-skip` semantics never needed; SKIP=env documented) — a contributor with no ruff gets a loud pointer, not a broken commit flow.
- **G-210 (P1)** — the guide parity test (MH-5b) needs a defined source of truth: the runbook's channel sections are the canonical text; `nextcraft guide` renders them via an extracted shared snippet (single source file, generated or duplicated-with-test); divergence = test failure. PLAN Task 5-1-02 mechanism named.
## Plan Edits Applied
1. G-201 → Task 3-1-01 fallback ladder + MH-3a reworded (probe → binfmt-full → deferral, never red lane)
2. G-202 → Overview wave-order paragraph corrected (P2 precedes P3 for founder priority, not technical force)
3. G-203 → Task 1-2-02 + Task 3-2-03 verification mechanism (temporary rc tag, deleted after proof)
4. G-204 → Task 2-2-02 five-assertion smoke gate pinned
5. G-205 → Task 1-2-04 reworded (founder-minted PAT with package-write; escalation question not silent wait)
6. G-206 → Task 1-1-01/1-1-03 rotation condition + ship-notes carry
7. G-207 → new P1 Wave 1-1 task (fleet-as-code committed in deploy/ci/)
8. G-208 → Task 2-1-02 single remediation constant, tested
9. G-209 → Task 1-2-05 README dev-section skip path documented
10. G-210 → Task 5-1-02 parity mechanism named (single source, divergence = test failure)
## Advisories (recorded, no action)
- a-201: docker.io rate limits on the release lane — bookworm main is fine; watch anonymous pull quotas on job-image pulls (runner-images:ubuntu-latest is docker.gitea.com, not docker.io).
- a-202: `GITEA_TOKEN` naming collision (automatic job token vs repo secret of the same name) — Gitea precedent lowest-level-wins; keep the repo secret name `GITEA_TOKEN` per R-13 but document the collision in the workflow comments.
- a-203: cache.host on CT 113 IP — if the LXC ever changes IP, cache breaks; fleet-as-code (G-207) documents the IP as a bring-up variable.
- a-204: image `latest` tag vs immutable release tags — `latest` is convenience-only; runbook always pins the release tag.
---
## v0.10 Grill (Phase 0 GRILL, 2026-09-30)
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** SHIP-WITH-BINDINGS · **Confidence:** 0.82 · **Date:** 2026-09-30
**Verdict summary.** The v0.10 B2B Employer Network plan (6 phases, 15 waves, 30 tasks) is feasible and correctly sequenced — every load-bearing tree citation re-verified against the working code and held (require_admin shape, the 402 gate order + hardcoded zeros, billing_admin checkout/mock-complete mechanics, the four main.py wiring sites, the web census, both throttle precedents, the tests/billing layout, scrypt probe). The researcher's six open risks are each carried by a plan task. But red-teaming found 10 binding defects: a REQUIREMENTS-vs-PLAN gate-order contradiction (REQ-10-004 literally says sessions compose AFTER the entitlement check — backwards, the G-307 class); "web render tests" specified against a harness that does not exist in-tree (D-059 declined it — the tests as written are undeliverable without smuggling in jsdom, violating R-66); a suite-breaking throttle trap (the global 30/min deque is module state shared across every tests/employers/ file — the whole fast suite runs inside one 60s window and 429s itself around test ~16); a credential-stuffing hole hiding in an ambiguous throttle-append rule (if failed passwords don't consume global budget, D-088's stated rationale is void); an EmailStr trap (email-validator is NOT installed — the natural pydantic choice is a boot-time ImportError and the first new dependency); a TOCTOU race on one-pending-claim-per-entity with no UNIQUE backing it (insert_application's check-first is safe only because a real constraint backs it); employer postings that would inherit source="admin" provenance if the admin route is copied; a re-tag fork between admin and employer edit paths (D-052 drift); a dual-schema pin pointed at contracts no task creates (the own-postings response has no named pydantic model); and a UX criterion inviting the executor to "fix" the 402 wire zeros (risk 7 stated as acceptance criteria). All ten amend existing tasks — nothing re-architects, no scope cuts required.
## Per-Axis Findings
| # | Axis | Verdict | Reason |
|---|------|---------|--------|
| 1 | Feasibility | CONCERN → fixed | All citations verified in-tree; but 4 tasks assume things the tree doesn't have: a render harness (G-312), EmailStr's dependency (G-315), a backing constraint for the claim 409 (G-316), a named own-postings pydantic contract for the MH-3a pin (G-319) |
| 2 | Scope | PASS | Big but REQ-mandated, heavy precedent reuse (the v0.6 46-task cadence); talent pages honestly left mock; no half-built surface found; no CUTs |
| 3 | Security | CONCERN → fixed | New trust-boundary code mostly well-specced (hashed tokens, lazy TTL, email-keyed throttles, 422 scrub, own-session mock-complete); defects: throttle-append ambiguity voids the stuffing bound (G-314), throttle-vs-KDF order unspecified (G-320a), claim insert race (G-316) |
| 4 | Honesty | CONCERN → fixed | "Render tests" would present source pins as render tests or force a harness dep (G-312); employer postings inheriting source="admin" is provenance dishonesty (G-317); UX criterion 3 contradicts the byte-stable 402 zeros (G-320b) |
| 5 | Drift | CONCERN → fixed | D-091 pins strong (payload-parity test + unchanged v0.9 suite); but the own-postings dual-schema pin has no pydantic contract to pin against (G-319), and employer edit would fork the admin re-tag semantics (G-318) |
| 6 | Sequencing | PASS | Wave order verified dependency-sound (P1 module → P2 claims → P3 surface → P4 writes → P5 money); pins land against base.py contracts, not pages — no hidden dependency |
| 7 | Researcher open risks | PASS | All six (scrypt burst, CORS preflight, 422 echo, sessionStorage, mock-complete own-session, example.com evidence) carried by named tasks with pins |
| 8 | Test coverage | CONCERN → fixed | Several must-haves unverifiable AS WORDED (render tests, G-312); the employers suite self-DOSes without a conftest-wide reset (G-313); stuffing untested (G-314) |
| 9 | One-entity/one-account races | CONCERN → fixed | One-APPROVED-owner is structurally sound (employer_accounts.employer_id UNIQUE → IntegrityError, pinned); one-PENDING-per-entity is check-first with NO backing constraint — TOCTOU (G-316) |
| 10 | Token entropy + storage | PASS | token_urlsafe(32) = 256-bit CSPRNG, sha256-at-rest, lazy 7-day TTL, revoke-on-logout, concurrent sessions — matches D-087 exactly; no finding |
## Binding Decisions (G-311..G-320 — applied to PLAN/REQUIREMENTS before execute)
**G-311 — REQ-10-004's gate-order wording is backwards; the session gate composes BEFORE the entitlement check.** REQ-10-004 says "employer sessions compose AFTER the existing entitlement enforcement" — literally: an unauthenticated caller hits the 402 gate before the 401, leaking entitlement state to anonymous callers and breaking the web client's SessionRequiredError-vs-UpgradeRequiredError discrimination (a 402 on an expired session renders an upgrade CTA to a logged-out visitor — a lie). The plan (Task 4-1-02, R-58) has the correct order; the requirement text contradicts it (the G-307 class: requirement prose naming a composition that must not exist). Decision: amend REQ-10-004 to "the entitlement check composes AFTER the employer session gate (401/403 precede any 402 — the R-58 admin-gate-first order preserved)". Applies to: REQUIREMENTS REQ-10-004; PLAN Task 4-1-02 (amendment note). Test: unauthenticated create → 401 (never 402); unclaimed-session create → 403 (never 402). Confidence 0.85.
**G-312 — no render harness exists in-tree: page/component tests are fetch-stub + source-pin, never "render tests".** Tasks 3-3-01/3-3-02 say "fetch-stub render tests" / "web render tests" — D-059 explicitly declined jsdom/happy-dom (v0.7, founder-scoped), the billing-entitlement.test.ts template's own header says "no render harness in-tree", and adding one is a new dependency violating R-66's zero-dep claim. As written, the executor either smuggles in jsdom or ships tests whose names lie about what they verify (the A-304 class). Decision: every "render test" is reworded to the D-059/E87 discipline — fetch-stub tests over engine-client employer functions (401 → SessionRequiredError, token header attached, logout revoke+clear) + source pins for component honest states (logged-out/claim-pending/loading/error branches present in source, the pins.test.ts discipline); jsdom/happy-dom are BANNED this milestone. Applies to: PLAN Tasks 3-3-01, 3-3-02 (amendment notes; 4-2-01/5-2-01/5-2-02 already say "web fetch-stub" — their loose "render" phrasing is covered by this G); MH-3b/3c/3e and MH-5c/5e read under this discipline. Confidence 0.85.
**G-313 — both throttle limiters reset via a SUITE-WIDE autouse fixture in tests/employers/conftest.py.** The global cap (default 30/min) is module-level process state (R-63); the entire tests/employers/ suite — register+login flows across test_auth, test_claims, test_claims_admin, test_postings_read, test_postings_write, test_checkout, test_checkout_e2e — runs in ONE process in well under 60 seconds, i.e. inside ONE window: ~90+ auth attempts against a 30-cap → guaranteed mid-suite 429s cascading through every downstream test. The plan places reset fixtures only in test_throttle.py's test list. Decision: tests/employers/conftest.py (a NEW file — the sanctioned deviation from billing's no-conftest layout, R-64) carries an autouse fixture clearing BOTH limiter module states before/after EVERY employers test; the per-file fixtures in test_throttle.py stay for its own window-math. Applies to: PLAN Task 1-2-02 (amendment note), D-096 operationalization. Test: the suite itself (it cannot pass without this — the trap is self-revealing but only after a day of flaky archaeology). Confidence 0.85.
**G-314 — the global cap counts every well-formed attempt, including failed passwords — or D-088's rationale is void.** The plan's "append only after ALL checks pass (a rejected attempt never consumes budget)" is copied from the sandbox create-cap, where "rejected" means server-rejected-by-validation. Read against auth, "all checks pass" plausibly includes password verification — under that reading, failed logins consume nothing and credential stuffing with rotating emails (the exact attack D-088 names: "bounding cross-email credential-stuffing volume") is UNBOUNDED, and the planned test "rejected attempts don't consume the global budget" would PIN the hole. Decision: the global deque appends for every well-formed login+register request that passes body validation, BEFORE the KDF runs — successes and failed passwords both consume budget; only 422-invalid bodies and the cap's own 429s are exempt; the per-email window keeps failures-only semantics. The test is reworded: "422-invalid attempts consume nothing; FAILED-PASSWORD attempts DO count; N distinct emails inside the window → the (cap+1)-th 429s (stuffing test, knob set low)". Applies to: PLAN Task 1-2-02 (amendment note), MH-1d. Confidence 0.75.
**G-315 — email fields are plain str with stdlib validation; pydantic EmailStr is BANNED.** email-validator is NOT in the venv (pyproject carries only pydantic/pydantic-settings; no in-tree email-validation precedent exists — identity keys on learner_id). Using pydantic's EmailStr in RegisterEmployerRequest/LoginEmployerRequest — the natural choice for "email + password, honest validation" — raises at model-build/validation time ("email-validator is not installed"), and fixing it means the FIRST new dependency, breaking R-66/D-086's zero-dep claim and the mypy zero-override regime. Decision: email is `str` with stdlib-only validation (non-empty, exactly one "@", non-empty domain, lowercased + stripped on normalize — mirrored in the store's _validate hook); the evidence domain extraction (R-65) already handles the parse. Applies to: PLAN Task 1-1-01 (amendment note). Test: register accepts "User@Example.COM" (normalized), rejects "not-an-email" with an honest 422 that never echoes the password (rides the G-312/1-2-03 pin). Confidence 0.85.
**G-316 — one-pending-claim-per-entity gets a structural backstop, not just check-first.** insert_application's check-first insert is race-safe ONLY because UniqueConstraint(posting_id, learner_id) backs it; "one PENDING claim per entity" is a CONDITIONAL uniqueness (rejected claims on the same employer_id are legal — "rejection frees the entity"), so no table-level UNIQUE exists and two concurrent claim POSTs both pass the check and both insert. Two threads reach the store (FastAPI sync endpoints run in a threadpool; the store uses check_same_thread=False per-call Sessions) — the 409 promise is TOCTOU. The one-APPROVED-owner arm IS structurally sound (employer_accounts.employer_id UNIQUE → IntegrityError, already pinned) — this G covers only the pending arm. Decision: `employer_claims.__table_args__` gains `Index("uq_pending_claim_per_entity", "employer_id", unique=True, sqlite_where=text("status = 'pending'"))` — SQLite supports partial indexes and SQLAlchemy emits them via create_all (the D-095 one-time path); insert_claim keeps its check-first 409 fast-path and SURFACES the IntegrityError as the same honest 409 under race. Applies to: PLAN Tasks 1-1-02 (schema), 2-1-02 (amendment note), MH-2c. Test: pin the partial index exists in sqlite_master; second concurrent-pending insert → 409 (store test surfaces IntegrityError, never a duplicate pending row). Confidence 0.75.
**G-317 — employer-created postings stamp their own provenance, never source="admin".** Task 4-1-02 mirrors the admin create route, whose Posting construction hardcodes `source="admin"`, `id=f"adm-…"`, `external_id=f"adm-…"` (marketplace_admin.py:221-243). Copied wholesale, an employer-authored posting presents as operator-authored — corpus provenance lies (A-304: the v0.6 ingest contract means source distinguishes seeded vs admin-authored; a third principal class silently inheriting the admin label makes "who wrote this" unanswerable and muddies the audit story the milestone sells). Decision: employer create stamps a distinct source (e.g., `source="employer"`) with its own id/external_id schemes (`emp-…`), keeping the (source, external_id) upsert identity unambiguous across all three authorship classes; the admin routes are untouched. Applies to: PLAN Task 4-1-02 (amendment note). Test: employer-created posting persists with the employer source stamp (pinned); admin routes still stamp "admin". Confidence 0.80.
**G-318 — employer edit composes the SAME re-tag path as the admin edit; no tag-semantics fork.** The v0.9 admin edit (PUT /v1/marketplace/admin/postings/{id}) calls update_posting_fields then re-tags via tag_posting — D-052: tags are DERIVED data, overwritten on edit. Task 4-1-02's employer PATCH specifies the own-drafts guard but is silent on re-tagging: an executor shipping edit-without-re-tag leaves employer-edited postings with stale tags — derived data drifting from content, a D-052 violation and a search-facet integrity hole (the exact drift class D-091 exists to prevent, on the tagging axis). Decision: the employer edit route composes the identical update_posting_fields + tag_posting path (tag failure honest — edit stands, tags stay, warning logs — the admin contract), with the own-drafts-only guard evaluated BEFORE the mutation (published/closed → 409 without side effects); PATCH-vs-PUT method naming is free choice. Applies to: PLAN Task 4-1-02 (amendment note). Test: employer edit on a mock provider re-tags (tags updated or TaggingUnavailable-swallowed-pinned); edit published → 409 AND no field/tag mutation occurred. Confidence 0.70.
**G-319 — the own-postings response is a NAMED pydantic contract; applicant data is COUNTS ONLY.** MH-3a/G-108 pins assert employer.ts against "ai_service/employers/base.py's pydantic contracts — both schemas", but no task's contract list includes the own-postings response: Task 1-1-01 names Account/Claim/Register/Login/Token/Me; Task 3-1-01 describes the route's payload in prose. The pin as specified either pins against nothing (a weak dict-shape grep) or blocks on a contract nobody was told to write. The route also needs a data source the plan doesn't name: applicant_count per posting, while the marketplace store's only read is list_applications(posting_id) — a per-posting N+1 across the employer's postings. Decision: (a) Task 3-1-01 adds named contracts to employers/base.py — `OwnPostingSummary` (PostingSummary reused from marketplace, PLUS applicant_count) and the facets/list response — so the MH-3a pin is schema↔schema; (b) the count rides a new in-pattern marketplace store read (e.g., `count_applications_by_posting(posting_ids) -> dict[str, int]`), never an N+1; (c) explicitly pinned: employer-facing responses expose applicant COUNTS ONLY — learner identifiers NEVER appear in any /v1/employers/* response (PII boundary, security-auditor P3/P5 review scope). Applies to: PLAN Tasks 1-1-01 (contract list), 3-1-01 (amendment note), 3-2-01, MH-3a. Confidence 0.80.
**G-320 — throttle-before-KDF order (a) and the 402 zeros are wire-only, never displayed truth (b).** (a) R-55's open risk (login bursts stack ~50 ms scrypt) is bounded ONLY if both throttle checks run BEFORE the derivation — a 429'd or globally-capped attempt must never burn KDF CPU. The plan never states the check order inside the login route. (b) UX acceptance criterion 3 says "limits shown are the store's truth" while the 402 payload hardcodes posts_used=0/posts_limit=0 (byte-stable per D-091/MH-4a) — as written the criterion invites the executor to "improve" the zeros (risk 7) or to render "0/0" as real limits (a lie). Decision: (a) route order is body-validation → global cap → per-email window → scrypt verify → failure-append (pinned: a 429'd attempt performs zero derivations); (b) criterion 3 reworded — the 402 payload carries reason + CTA only; real limits render exclusively from GET /v1/employers/billing/entitlement (the entitlement view is the limits truth source, Task 5-1-01). Applies to: PLAN Task 1-2-02 (a, amendment note), Task 4-2-01 + UX criterion 3 (b, amendment note). Tests: (a) a capped email's login attempt completes in ≪ KDF time / no derivation side-effect observable — pinned as "429 before KDF" via the throttle test's knob math; (b) web source pin: the 402 branch renders reason + CTA and fetches nothing from the payload's posts_used/posts_limit. Confidence 0.80.
## Scope Cuts
None. Every phase maps to a REQ-10-001..005 row; the one candidate (employer draft-edit, the least-essential P4 arm) is named by REQ-10-004 and is cheap once G-318 forces admin-parity — cutting it would unilaterally narrow a locked requirement. Talent pages staying mock is the honest deferral the plan already makes (D-092).
## Advisories (recorded, no plan edit)
- **a-311:** Employer mock-complete "own session" = checkout rows whose employer_id equals the session's claimed entity — including operator-created rows for that entity (same buyer, D-076 fallback path; not a G-301 violation). Pin the test's fixture accordingly so the 403 arm uses a DIFFERENT entity's row, not an admin-created same-entity row.
- **a-312:** Per-email throttle inherently permits targeted lockout DoS (5 failures → victim's email 429s for 15 min). Accepted within D-088 (email-keying is locked, never re-litigated); success-clears-window is the mitigation; surface copy need not mention it.
- **a-313:** .env.example throttle entries are documentation, not "default-off" — the Settings ints are always active with defaults 5/30 (the R-60d shape). Write the comments that way.
- **a-314:** Logout on an already-revoked token → 401 (strict) is fine; do not "fix" it to 204 — idempotent-logout would let a stolen-tab replay confirm revocation state.
- **a-315:** Register's 409 duplicate-email necessarily reveals account existence — standard and unavoidable; the login 401 uniformity (already specified) is the enumeration boundary that matters.
## Plan Edits Applied
1. G-311 → REQUIREMENTS REQ-10-004 wording + PLAN Task 4-1-02 amendment note
2. G-312 → PLAN Tasks 3-3-01, 3-3-02 amendment notes
3. G-313/G-314/G-320a → PLAN Task 1-2-02 amendment note
4. G-315 → PLAN Task 1-1-01 amendment note
5. G-316 → PLAN Task 2-1-02 amendment note
6. G-317/G-318/G-311 → PLAN Task 4-1-02 amendment note (single combined note)
7. G-319 → PLAN Task 3-1-01 amendment note
8. G-320b → PLAN Task 4-2-01 amendment note + UX criterion 3 clause
---
## v0.11 Grill (Phase 0 GRILL, 2026-10-01)
### Run: 2026-10-01 03:00 (mode: mechanical, focus: all — v0.11 placement tracking)
### Verdict: Proceed (confidence: 0.82)
**Verdict summary.** The v0.11 plan (6 phases, 15 waves, ~30 tasks) is feasible, correctly sequenced, and — rare for a plan this audit-flavored — unusually honest about its own limits (R-67's three methodology gaps are pinned into the export). Every load-bearing tree citation re-verified held: the billing transition-matrix shape, the `decide_claim` same-transaction precedent, the `submitted`-only application status (zero migration), the G-313 conftest reset pattern, the G-319 counts-only pin, all four main.py wiring sites, the engine-client error-class rails, and the overview mock block D-109 replaces. The two genuinely novel mechanisms (the hash chain, the export) each carry dedicated adversarial tests (tamper/fork/determinism), and the one coherence gap research found (R-80 re-hire) is pinned to a route. But red-teaming found **8 binding defects**: the export's verification story overclaims by omission (the chain detects in-place mutation of an intact sequence — it cannot detect a wholesale DB rewrite, and no instruction tells the auditor how to use prior exports as the anchor); **the learner status panel cannot find the learner's application on revisit** (the read route is keyed by `application_id`, no route exists to discover it for a posting, and the plan never says how the panel obtains it — the happy path only works in the same browser session); the apply route's idempotent arm returns a 200 "applied" confirmation for a withdrawn (terminal) application — a lie D-104's withdraw creates; `role_title` accepts the empty string (the 422 arm pins absence, not emptiness); revoked placements are counted but their evidence bundles are unspecified (an unverifiable `revoked_count`); the re-hired PlacementRow's `decided_at` is unspecified; the operator route's `to` has no 422-vs-409 discrimination; and nothing pins the live UI wording against "CIRR-verified" theater. All eight amend existing tasks — nothing re-architects, no scope cuts required.
### Per-Axis Findings
| # | Axis | Verdict | Reason |
|---|------|---------|--------|
| 1 | Feasibility | PASS | Outcome is unmeasurable without this milestone (no hire state exists in-tree); every prerequisite shipped; 6-phase shape matches the two shipped precedents |
| 2 | Scope | PASS | Explicit out-of-scope list; R-67's three pilot gaps pre-disclosed in the methodology (the hidden-requirement class handled better than any prior milestone); the one boundary change (G-319 supersession) is decisioned with a pin split |
| 3 | Architecture | CONCERN → fixed | Chain NOT over-engineered (it IS the deliverable; the cheap version) but UNDER-scoped (G-321: the walk cannot detect a wholesale DB rewrite — export-to-export prefix continuity is the anchor); the mine-read gap strands the learner status panel on revisit (G-322); D-100 cross-store transaction verified sound (one Session, one commit — the two-commit alternative voids the tamper-evidence) |
| 4 | People | PASS | Roster matches the work; the security-auditor's four review points cover exactly the novel-risk surfaces |
| 5 | Timeline | PASS | Consistent with v0.9/v0.10 identical-shape precedents; the flush-then-hash-then-commit append is the one new mechanism, mitigated by R-70's in-tree template + tamper/fork arms |
| 6 | Budget | PASS | Zero new deps, zero knobs, zero workflow changes; chain verification is O(n) sha256 over tiny rows |
| 7 | Risks | CONCERN → fixed | Pre-mortem done (R-80 found + pinned by research itself; all 10 open risks map 1:1 to tasks); NEW cross-decision defect found: apply → withdraw → re-apply returns a 200 "applied" lie (G-323 — D-043 × D-099 × D-104 interaction nobody mapped) |
| 8 | Governance | CONCERN → fixed | Decisions traceable + discipline enforced (the R-80 escalation flow is the pattern working); MISSING: live-surface wording discipline — "CIRR-verified" theater drift unpinned (G-328); the operator route's 422-vs-409 split unspecified (G-327) |
| 9 | Adoption | CONCERN → fixed | Export is honestly-minimal (dataset + disclosure, not theater) PROVIDED G-321/G-328 hold; counting gameable only via operator-gated claim approval — honestly disclosed (a-321); empty-string role_title accepted (G-324) |
| M | Meta | CONCERN → fixed | Revoked placements' evidence bundles unspecified — an auditor handed revoked_count with no evidence has an unverifiable number, and correction history is what an auditor most wants to inspect (G-325); the re-hired row's decided_at unpinned (G-326); simplest-80% version = this plan (no cut found) |
### Binding Decisions (G-321..G-328 — applied to PLAN before execute)
**G-321 — the export methodology states the chain's proof scope, its limit, and the auditor's real anchor.** The walk proves in-place mutation/fork detection of an intact sequence; it does NOT detect a wholesale DB rewrite (delete all events, re-append a fresh plausible chain from GENESIS — it walks green; GENESIS is a constant with no external anchor). PlacementRow itself is not hash-chained — a raw-SQL status flip leaves a chain-consistent ledger with a lying row. Decision: the methodology's `verification` block + the summary's verification instructions state (a) what the chain proves, (b) what it does not (wholesale replacement — the auditor's defense is export-to-export continuity: a prior export's `ledger.events` must appear as an exact prefix of a later export's, and `head_hash` at the prior export must equal the hash at that prefix position), and (c) an event↔row cross-consistency check (every active placement has `placement.created`; every revoked has `placement.revoked`; revoked_count matches the `placement.revoked` events). Amends Tasks 4-1-01, 4-1-02; asserted present by 4-1-03. Confidence 0.80.
**G-322 — the mine point-read: the learner status panel must never depend on client-persisted application_ids.** The read route is keyed by application_id, but no route discovers a learner's application for a posting (the idempotent apply return yields the id only in-session; there is no by-(posting, learner) read anywhere in-tree) — REQ-11-005's outcome visibility breaks on revisit, the common case for a job board. Decision: store `get_application_by_posting_learner(posting_id, learner_id)` (the insert_application unique key — the get_posting point-read shape) + route `GET /v1/marketplace/applications/mine?posting_id=&learner_id=` on the unchanged D-043 gate stack, returning `{application_id, status}` or 404. The job-detail panel composes: mine-read → found? render the status panel via D-104's read : not found? render the apply CTA. Amends D-104 (additive route), Tasks 5-1-01 + 5-2-01; tests in test_learner_status.py (own/foreign/unverified/unknown arms + 404-when-none). Confidence 0.85.
**G-323 — apply on an existing terminal application → 409, never a 200 "applied" confirmation.** insert_application's idempotent arm (marketplace/store.py:341-344) returns the EXISTING application with 200 — after D-104's withdraw makes it terminal, re-apply tells the learner they applied to a job they can never re-enter the pipeline for. Decision: the apply route's idempotent arm gains a terminal-state guard: returned application in `withdrawn`/`rejected`/`hired` → 409 `{"reason": "application_terminal", "status": …}` (the posting_not_published 409 shape, marketplace.py:366-373), never a 200; no new application row (the unique key stands); recovery is visibility (G-322's mine-read + status panel), not resurrection. Amends Task 2-2-01's test list (apply → withdraw → re-apply → 409). Confidence 0.75.
**G-324 — `role_title = Field(min_length=1, max_length=200)`; start_date keeps no semantic range.** Pydantic "required" rejects absence, not emptiness — the house Field shape (marketplace/base.py:68-71) is not specified for the hire body and `role_title: ""` would enter the hashed ledger. Decision: min_length=1 (pinned beside the missing-fields 422 arm), max_length=200 (bounds absurd payloads out of the hashed rows). start_date stays a bare pydantic date — retroactive + future legal per D-103; garbage dates surface as anomalous by_cohort_month buckets in the operator overview and are corrected via the revoke/re-hire lane; the methodology's attestation note covers date integrity. Amends Tasks 1-1-01, 2-1-01. Confidence 0.80.
**G-325 — evidence_bundles cover EVERY placement (active + revoked), each with the full event history.** An auditor handed `revoked_count: 3` with no evidence for the three revocations has an unverifiable number — and the correction history (hired → revoked → rehired) is precisely what an auditor most wants to inspect. Decision: bundles cover active AND revoked placements with the full event history incl. placement.revoked/placement.rehired; `cohort` stays active-only; revoked_count equals the placement.revoked events (cross-checkable, rides G-321(c)). Amends Tasks 4-1-01, 4-1-03 (completeness test: every placement active-or-revoked has a bundle). Confidence 0.80.
**G-326 — the re-hired PlacementRow's decided_at = the re-hire event's store-stamped created_at.** D-102 stamps decided_at from "the hired transition's created_at" — a re-hire has no hired transition; an executor will guess. Decision: the same `datetime.now(UTC)` stamp rule extends to the re-hire arm; stated in the module docstring + asserted in the revoke→re-hire test arm. Amends Task 2-2-02. Confidence 0.85.
**G-327 — the operator transition route's `to` is a pydantic Literal of the seven D-099 statuses.** Unknown value → 422 (body validation), matrix-illegal known value → 409 InvalidTransition — the route never sees a status outside the vocabulary (no 500s, no silent acceptance). Test arm in test_admin_lane.py (garbage `to` → 422, never 500/409). Amends Task 2-2-02. Confidence 0.85.
**G-328 — live surfaces never present attestation as verification.** Nothing pins the admin placements block / overview copy against the theater drift — "CIRR-verified placements" or "verified: 1,000" on a live surface presents attestation as third-party verification (A-304), the exact self-certification trap the milestone could fall into. Decision: live surfaces label counts as recorded placements ("distinct learners placed", "placements recorded"); "CIRR" NEVER appears as a verification claim on any live surface — "CIRR-style" describes the export format in release notes/methodology only; the summary's title renders the attestation language ("employer-attested, operator-overseen"). Amends Task 4-2-01 (copy) + UX criterion 5's test arm (source pin: no "verified" placement wording on any new surface). Confidence 0.75.
### Advisories (recorded, no action)
- a-321: No technical self-hire guard exists (learner handles and employer accounts are disjoint namespaces); the anti-gaming control is operator claim-approval diligence + per-employer overview detection + the revoke lane — already disclosed as R-67 gap (c); do not add a theater cross-check.
- a-322: Withdraw shares the apply-rate window (5/60s per learner, D-104) — a learner withdrawing 5 applications quickly locks themselves out of applying for the window's remainder. Honest, bounded, no new knob.
- a-323: The matrix arm (read status → validate) is check-then-act, but the terminal guard makes a concurrent loser's write a 409-conflict on re-read; the D-111 concurrent-transition test covers the shape; no pessimistic locking at pilot volumes.
- a-324: The employer pipeline list necessarily carries learner_id per row (own postings only) — the G-319 pin split's application-scoped arm working as decisioned; the P2 security review confirms the list route's cross-entity arms return 403 with no data, same as the transitions.
### Escalations
None. All eight findings resolve with evidence-backed defaults (each names its amends-task and its test); nothing re-architects, no axis falls below 0.60.
### Plan Edits Recorded (applied by the orchestrator)
1. G-321/G-325/G-328 → PLAN Tasks 4-1-01, 4-1-02, 4-1-03, 4-2-01 amendment notes + UX criterion 5 arm
2. G-322 → PLAN Tasks 5-1-01, 5-2-01 amendment notes (additive route per D-104)
3. G-323 → PLAN Task 2-2-01 amendment note (apply-route terminal guard + test arm)
4. G-324 → PLAN Tasks 1-1-01, 2-1-01 amendment notes
5. G-326/G-327 → PLAN Task 2-2-02 amendment notes
+156 -137
View File
@@ -2,23 +2,17 @@
## Persona Roster
> **v0.7 update (RESEARCH, lead-developer assessment):** milestone = Test Harness Review (NFR, D-047's successor — refinement-only). **Activated:** backend-engineer (pytest config surface: markers, timeouts, filterwarnings, coverage wiring, `session.exec()` conversions, resource cleanup, mypy config — its config.py/pyproject/store territory), frontend-engineer (web/cli test wiring: turbo `web#test` task, c8 coverage, ESLint flat config adoption D-048, source-pin triage D-057, recharts 2→3 D-049 — its apps/web + apps/cli territory), data-engineer (store-layer `session.query()` → `session.exec()` migrations touching its marketplace/identity store territory; version single-source D-061), lead-developer (coordination + wave-order D-062). **security-auditor re-activated (phase-specific)** for P3 resource-cleanup review (UploadFile/socket close paths must not break the PII-redaction guarantees) and final review (P6). **Deactivated:** ai-engineer (no LLM-facing work; test changes touch its modules only as subjects), sandbox-engineer (no fabric work; its tests are consolidated but owned by the test-infra work), identity-engineer, voice-engineer, cli-engineer (v0.3.6/v0.4.6 hotfix line stable; CLI test changes are small and ride frontend-engineer's node:test territory), design-system-engineer.
> **v0.6 update (RESEARCH, lead-developer assessment):** milestone = Marketplace Engine (D-047). **Activated:** data-engineer (6th D-027 store family — five tables, seed port, TS wire types dual-schema sync, its core constraint territory), ai-engineer full activation (tagging/enrichment/resume D-020 prompts + EmbeddingProvider protocol — LLM-facing work returns), frontend-engineer (client shells, engine-client marketplace extension, generateStaticParams removal ×2, SEO metadata + client JSON-LD), backend-engineer (module wiring, config surface: AI_EMBED_*/AI_ADMIN_TOKEN/AI_PUBLIC_BASE_URL, sitemap/robots routes). **security-auditor re-activated (phase-specific)** for admin-token fail-closed (P1), resume upload bounds + PII-logs (P4), sitemap origin (P5), final review (P6). **Deactivated:** identity-engineer (v0.5 custom — module shipped; only the `require_verified_adult` gate import is reused), voice-engineer, sandbox-engineer (no voice/sandbox/telemetry work), cli-engineer, design-system-engineer (existing primitives cover the marketplace shells).
> **v0.5 update (RESEARCH, lead-developer assessment):** milestone = Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease. **Reactivated:** voice-engineer (real server STT/TTS, its v0.3 territory), sandbox-engineer (design/sim environment kinds + the seq-ack protocol on the fabric/agent it owns), frontend-engineer (defense audio upload, identity enrollment flow, kind-aware build surface). **New custom persona: identity-engineer** (KYC/identity domain — 5th store, provider protocol, age-gate dependencies, PII hygiene). **security-auditor re-activated (phase-specific)** for identity PII + age-gate bypass + audio upload attack surface (phases 1-4 review, final phase). ai-engineer light-touch (no LLM-facing work this milestone). backend-engineer retains settings/factory wiring + turbo/root scripts. cli-engineer inactive (v0.3.6 hotfix shipped; no CLI work planned). design-system-engineer/data-engineer inactive (one TS types extension only — data-engineer light-touch for variants/identity types).
> **v0.11 update (RESEARCH, lead-developer assessment):** milestone = Placement Tracking and Audit (Feature — ai_service/placements/ module: 9th D-027 store with the hash-chained placement_event ledger, the application state machine via one transactional transition_application, /v1/employers/applications per-target routes, learner withdraw/status routes in marketplace.py, the placements_admin operator lane + export, four web surfaces). **Activated:** lead-developer (coordination + the R-80 re-hire coherence review + the G-319 pin-split review), backend-engineer (owns ai_service/placements/* + api/placements_admin.py + the employer application routes + learner routes in marketplace.py + deps.py get_placement_store + main.py wiring — REQ-11-001..005 core), data-engineer (owns the 2-table placement store schema: PlacementRow partial-unique + PlacementEventRow global hash chain flush-then-hash-then-commit, canonical serialization, the D-102 revoke semantics — REQ-11-001/002), frontend-engineer (owns the four D-109 surfaces: /employer/applications page, overview placements section, job-detail status panel, admin placements block + engine-client placements section + packages/types/placement.ts — REQ-11-003/005), security-auditor (phase-specific: the hash-chain tamper evidence, the G-319 supersession PII review, gate-order pins, export integrity — REQ-11-001/002/004). **Deactivated:** cli-engineer, devops-engineer, sandbox-engineer, ai-engineer, identity-engineer (learner gates compose unchanged — D-104), voice-engineer, design-system-engineer (all unchanged surfaces this milestone).
### lead-developer
```yaml
active: true
phase_specific: false
reason: Coordinates the v0.7 NFR milestone: harness wiring → composition → warning elimination → lint hygiene → type/dep hardening (D-062 wave order); owns the coverage-evidence handoff between P1 and P2
reason: Coordinates the v0.11 Placement Tracking milestone (store → state machine → reporting → export → visibility waves); owns wave order + the R-80 re-hire coherence decision's implementation review (the placement.rehired route/event shape) + the G-319 pin-split review (counts-only stays on own-postings lists; learner_id on application-scoped routes only) + the placement.ts ↔ placements/base.py dual-schema pin review + the D-100 cross-store transaction discipline review (one transition_application, no second status writer)
domain: coordination
frameworks:
- next.js
- turborepo
- pnpm
- fastapi
constraints:
- pragmatic
- battle-tested defaults
@@ -26,93 +20,143 @@ constraints:
territory:
- "**/package.json"
- "**/turbo.json"
- "**/pnpm-workspace.yaml"
- "**/tsconfig.json"
- "apps/ai-service/pyproject.toml"
- "apps/web/tests/pins.test.ts"
```
### data-engineer
```yaml
active: true
phase_specific: false
reason: v0.7 — store-layer session.query()→session.exec() migration in its marketplace/identity territory (14 sites, D-052/REQ-7-003), version single-source D-061 (pyproject.toml), SQLModel deprecation remediation
domain: data
frameworks:
- sqlmodel
- pydantic
- typescript
constraints:
- schema-first
- type-safe
- dual-schema-sync (TS/Python changes made in both places)
- idempotent-upserts ((source, external_id) + (posting_id, learner_id) unique constraints)
territory:
- "apps/ai-service/ai_service/marketplace/base.py"
- "apps/ai-service/ai_service/marketplace/store.py"
- "apps/ai-service/ai_service/marketplace/ingest.py"
- "packages/types/**"
- "packages/mock-data/**"
```
### ai-engineer
### devops-engineer
```yaml
active: false
phase_specific: false
reason: v0.7 DEACTIVATED — no LLM-facing work; the modules it owns are only test subjects (coverage/markers touch tests/, not ai_service/llm/ or agents/)
domain: ai
reason: v0.11 DEACTIVATED — no infra changes; the placements module rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
domain: devops
frameworks:
- pydantic
- httpx
- docker
- docker-compose
- gitea-runner
- github-actions-syntax (gitea-compatible subset)
- bash
constraints:
- sibling-mode-socket-trust (org-scoped private repos only; accepted-risk documented)
- secrets-via-actions-secrets-or-founder
- job-token-cannot-publish-packages
- cache-host-pinning
- fast-lanes-only-in-ci (real_server/userns never in job containers, D-063)
territory:
- ".gitea/workflows/**"
- "Dockerfile"
- ".dockerignore"
- ".pre-commit-config.yaml"
- "deploy/runner/**"
```
### cli-engineer
```yaml
active: false
phase_specific: false
reason: v0.11 DEACTIVATED — no CLI changes; the placement surface is server+web only. Roster + v0.8 territory retained.
domain: cli
frameworks:
- node
- typescript
- node-sea
- esbuild
- postject
- dpkg-deb
- posix-sh
constraints:
- stdlib-only-runtime
- thin-wrapper
- fail-loud-exit-codes
- postinst-never-fails-dpkg (advisory doctor, D-067)
- deb-no-systemd-unit (deploy --service owns units, D-067)
- guide-offline-first (zero runtime fetches, D-072)
- deb-version-policy (tag minus leading v)
territory:
- "apps/cli/**"
- "scripts/install.sh"
- "scripts/release-assets.sh"
- "scripts/build-deb.sh"
```
### sandbox-engineer
```yaml
active: false
phase_specific: false
reason: v0.11 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
domain: infra
frameworks:
- python
- linux-namespaces
- docker (degrade semantics only — no docker backend)
- pytest
constraints:
- provider-agnostic-protocol
- never-call-cloud-in-tests
- mock-first (structured_completion runs against MockLLMProvider)
- capability-probe-is-truth-source (markers only flavor remediation, D-068)
- reject-not-fallback (unisolated learner code unacceptable in deployed schools; supersedes A-101)
- degrade-keeps-core-features (chat/agents/grading untouched)
- probe-semantics-verbatim (unshare --user --map-root-user true, 10s timeout)
territory:
- "apps/ai-service/ai_service/marketplace/tagging.py"
- "apps/ai-service/ai_service/marketplace/enrichment.py"
- "apps/ai-service/ai_service/marketplace/resume.py"
- "apps/ai-service/ai_service/marketplace/embedding/**"
- "apps/ai-service/ai_service/marketplace/search.py"
- "apps/ai-service/ai_service/llm/**"
- "apps/ai-service/ai_service/agents/structured.py"
- "apps/ai-service/ai_service/prompts/**"
- "apps/ai-service/ai_service/sandbox/**"
- "apps/ai-service/ai_service/main.py"
- "apps/ai-service/ai_service/api/sandboxes.py"
- "apps/ai-service/tests/sandbox/**"
- "apps/ai-service/tests/helpers/userns.py"
```
### security-auditor
```yaml
active: true
phase_specific: true
reason: v0.11 re-activated (phase-specific) — P1 ledger review: the hash-chain construction (canonical JSON rules, GENESIS pinned, UNIQUE(prev_hash) fork backstop, flush-then-hash-then-commit single append path, no delete path — R-70), partial-unique active-placement guard (G-316 shape); P2 gate review: G-311 gate order on application transitions (401 → 403-claim → 403-cross-entity → 409), the G-319 supersession PII review (learner_id = opaque handle, application-scoped only, anti-enumeration preserved — R-79); P4 export integrity (determinism rules, generated_at exclusion, methodology honesty gaps stated — R-67/R-68/R-73); P6 final review (no PII/credentials in logs or releases)
domain: security
frameworks:
- pytest
- httpx
constraints:
- STRIDE-classified
- secrets-via-env-only (D-014)
- secrets-never-logged
- fail-closed-on-unconfigured
- never-display-forgeable-as-verified (A-304 — the export methodology states its own limits)
- tamper-evidence-tested (an audit ledger nobody has tried to break is theater, D-111)
territory:
- "apps/ai-service/ai_service/placements/**"
- "apps/ai-service/ai_service/api/placements_admin.py"
- "apps/ai-service/tests/placements/**"
```
### backend-engineer
```yaml
active: true
phase_specific: false
reason: v0.7 core activation — owns the pytest config surface (markers/strict-markers, pytest-timeout 60s/180s, filterwarnings error-first D-056, pytest-cov), conftest leak fixes (AI_PROVIDER restore, seed swallow), session.exec() conversions + UploadFile/socket/file cleanup in api/+scripts/, mypy config + ai:typecheck script, .env.example docs
reason: v0.11 ACTIVATED — owns the placements module core: ai_service/placements/base.py (pydantic contracts + the D-099 transition matrix + event-name vocabulary), store.py wiring via deps.py get_placement_store; the ONE transactional transition_application method (status write + ledger append + placement-on-hired, one commit — D-100) + the R-70 flush-then-hash-then-commit append shape; api routes: employer per-target transitions under /v1/employers/applications (G-311 gate order — D-105), learner withdraw/status in marketplace.py (D-043 stack unchanged — D-104), placements_admin.py operator lane (generic transition + revoke + R-80 re-hire route + overview/export reads — D-106/D-108); main.py wiring (placement_store lifespan + close, placements_admin mount); no new config knobs (no new throttles)
domain: backend
frameworks:
- fastapi
- pydantic-settings
- httpx
- bash
- turborepo
- pnpm
constraints:
- secrets-via-env-only (D-014; keys never in code or commits)
- secrets-via-env-only (D-014)
- idempotent-scripts
- fail-closed-admin (unset AI_ADMIN_TOKEN = admin routes 403 + loud boot warning)
- fail-closed-admin
- never-call-cloud-in-tests (zero network by construction, D-111)
- one-status-writer (placements store is the ONLY post-creation writer of application.status — R-78)
- no-cross-family-foreign-keys (G-302 ID references at read)
territory:
- "apps/ai-service/ai_service/config.py"
- "apps/ai-service/ai_service/placements/**"
- "apps/ai-service/ai_service/main.py"
- "apps/ai-service/ai_service/api/marketplace.py"
- "apps/ai-service/ai_service/api/marketplace_admin.py"
- "apps/ai-service/ai_service/api/deps.py"
- "apps/ai-service/scripts/**"
- "apps/ai-service/.env.example"
- "apps/ai-service/ai_service/api/**"
- "apps/ai-service/tests/placements/**"
- "apps/ai-service/pyproject.toml"
- "package.json"
- "turbo.json"
```
### frontend-engineer
```yaml
active: true
phase_specific: false
reason: v0.7 core activation — owns web/cli test wiring (turbo web#test task, root test/web:test scripts, c8 coverage around tsx --test), ESLint flat-config adoption for all JS/TS workspaces (D-048) wired into turbo lint, source-pin triage per D-057, recharts 2→3 (D-049), CWD-fragility fixes in web tests
reason: v0.11 ACTIVATED — owns the four D-109 surfaces: new /employer/applications page (own pipeline with status badges + per-target transition CTAs + hire form {role_title, start_date}), employer overview placements section (replaces the mock-labeled placement metrics block — D-092 never-fake-convert), job-detail-client application-status panel (learner history + placement confirmation via D-104 read, EngineError.status discrimination, no new error classes), admin moderation placements block (overview counts + per-employer lists + export links, adminToken transport); engine-client placements section (employer/learner/admin functions, AbortController everywhere, SessionRequiredError reuse — D-110); packages/types/placement.ts dual-schema pinned (D-110); talent badge stays absent (A-304)
domain: frontend
frameworks:
- react
@@ -121,40 +165,65 @@ frameworks:
constraints:
- component-first
- server-components-default
- honest-state-surfaces (404 on missing posting, verify-CTA on 403, empty boards)
- no-build-time-db-reads (D-039 export build stays repo-only)
- honest-states (loading/error/empty/logged-out never mocked)
- abortcontroller-everywhere (A-406)
- session-gated-shell (401 → honest logged-out state, never a retry loop)
territory:
- "apps/web/**"
- "packages/ui/**"
- "packages/types/placement.ts"
```
### security-auditor
### ai-engineer
```yaml
active: false
phase_specific: false
reason: v0.11 DEACTIVATED — no LLM-facing work; placement flows need no tagging/enrichment/embedding changes (hire transitions record decisions; no AI surface is touched).
domain: ai
frameworks:
- pydantic
- httpx
- pytest
constraints:
- provider-agnostic-protocol
- never-call-cloud-in-tests
- mock-first
territory:
- "apps/ai-service/ai_service/llm/**"
- "apps/ai-service/ai_service/agents/**"
- "apps/ai-service/ai_service/marketplace/**"
- "apps/ai-service/ai_service/prompts/**"
```
### data-engineer
```yaml
active: true
phase_specific: true
reason: v0.7 re-activated (phase-specific) — P3 resource-cleanup review (UploadFile/socket close paths must not weaken the PII-redaction/stream-close guarantees), P6 final review (NFR purity gate: zero feat: commits)
domain: security
phase_specific: false
reason: v0.11 ACTIVATED — owns the 2-table 9th store schema (D-112): placements/store.py with PlacementRow (id, learner/employer/posting/application refs, role_title, start_date DATE, status active|revoked, created_at, decided_at; partial UNIQUE application_id WHERE status='active' — the G-316 shape, D-102) + PlacementEventRow (append-only global ledger: nullable indexed application_id + placement_id, event_type, actor_type employer|learner|operator|system, actor_ref, payload, prev_hash UNIQUE + hash, created_at — D-101); the canonical serialization rules (sort_keys + separators, _as_utc(dt).isoformat() pinned, no floats — R-79/R-69); flush-then-hash-then-commit append (R-70); the R-80 placement.rehired event vocabulary; cross-store transaction (one Session writes ApplicationRow + both placement tables — D-100); tz discipline + create_all idempotent compat (D-095 shape); _validate/_normalize + surfaced IntegrityError
domain: data
frameworks:
- pytest
- httpx
- sqlmodel
- pydantic
- typescript
constraints:
- STRIDE-classified
- fail-closed (unset admin token never opens routes)
- bounded-uploads (resume 2MB/format guard)
- secrets-never-logged (_sanitize key redaction)
- schema-first
- type-safe
- dual-schema-sync
- idempotency-by-constraint (partial UNIQUE + check-first)
- audit-append-only (the ledger has NO delete path — R-70)
- canonical-serialization-pinned (one normalization, test-pinned)
territory:
- "apps/ai-service/ai_service/api/marketplace.py"
- "apps/ai-service/ai_service/api/marketplace_admin.py"
- "apps/ai-service/ai_service/marketplace/resume.py"
- "apps/ai-service/ai_service/marketplace/embedding/openai_compat.py"
- "apps/ai-service/ai_service/api/identity.py"
- "apps/ai-service/ai_service/placements/store.py"
- "apps/ai-service/tests/placements/test_store.py"
- "packages/types/**"
```
### identity-engineer
```yaml
active: false
phase_specific: false
reason: v0.5 custom persona — module shipped complete (identity/, gates, /v1/identity/*); v0.6 only REUSES the require_verified_adult import on the marketplace apply route (D-058). No identity-domain work.
reason: v0.11 stays DEACTIVATED — the milestone composes the EXISTING learner gates unchanged (D-104: the withdraw/status routes ride the D-043 stack — G-5 allowlist → verified 18+ → apply-rate window); no identity-module changes. Reactivation is a founder call if/when a real KYC vendor lands.
domain: identity
frameworks:
- fastapi
@@ -163,7 +232,6 @@ frameworks:
constraints:
- pii-never-stored-raw
- pii-never-logged
- mock-verdict-honesty
- gate-composition-order
territory:
- "apps/ai-service/ai_service/identity/**"
@@ -174,7 +242,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.5 territory — voice shipped complete (openai-audio provider, defense routes, browser SR/TTS client); no v0.6 voice work. The EmbeddingProvider protocol MIRRORS the voice pattern (D-051) but the voice module itself is untouched.
reason: v0.5 territory — voice shipped complete; no v0.11 voice work.
domain: ai-media
frameworks:
- httpx
@@ -185,66 +253,17 @@ constraints:
- provider-agnostic-protocol
- never-call-cloud-in-tests
- key-redaction
- bounded-audio-in-memory
territory:
- "apps/ai-service/ai_service/voice/**"
- "apps/ai-service/tests/voice/**"
- "apps/web/components/learner/defense-session.tsx"
```
### sandbox-engineer
```yaml
active: false
phase_specific: false
reason: v0.3/v0.5 territory — fabric + env kinds + seq-ack shipped complete; no v0.6 sandbox/telemetry work (marketplace data plane never enters the namespace fabric).
domain: infra
frameworks:
- python
- linux-namespaces
- pytest
constraints:
- stdlib-only-agent
- acks-are-advisory
- thread-safe-spool-trim
- digest-kind-agnostic
territory:
- "apps/ai-service/ai_service/sandbox/**"
- "apps/ai-service/ai_service/telemetry/**"
- "apps/ai-service/scripts/sandbox-agent.py"
- "apps/ai-service/ai_service/variants/templates.py"
- "apps/ai-service/tests/sandbox/**"
- "apps/ai-service/tests/telemetry/**"
```
### cli-engineer
```yaml
active: false
phase_specific: false
reason: No CLI work in v0.6 (the binary attaches to every release per the ongoing-binaries requirement; the ship workflow handles it — no apps/cli changes).
domain: cli
frameworks:
- node
- typescript
- node:test
- esbuild
- node-sea
- posix-sh
constraints:
- stdlib-only-runtime
- thin-wrapper
- timeout-every-spawn
- fail-loud-exit-codes
territory:
- "apps/cli/**"
- "scripts/install.sh"
- "scripts/release-assets.sh"
```
### design-system-engineer
```yaml
active: false
phase_specific: false
reason: No design-token or primitive work in v0.6 (existing primitives + JobFilters cover the marketplace shells); roster retained.
reason: No design-token or primitive work in v0.11 (the applications page reuses existing Badge/Button/Card primitives); roster retained.
domain: frontend
frameworks:
- tailwindcss
@@ -260,7 +279,7 @@ territory:
| Persona | Phases | Removed After |
|---------|--------|---------------|
| security-auditor | 1 (admin token + apply gates), 3 (embedding key redaction), 4 (resume bounds + PII), 5 (sitemap origin), 6 (final review) | milestone complete |
| security-auditor | 1 (store/ledger review: hash chain, canonical serialization, partial unique, no-delete path), 2 (gate order + G-319 supersession PII review on application routes), 4 (export integrity: determinism rules, methodology honesty), 6 (final review: no PII in logs/releases) | milestone complete |
All other personas span the milestone. Deactivated personas receive no tasks.
@@ -268,9 +287,9 @@ All other personas span the milestone. Deactivated personas receive no tasks.
| Conflict | Resolution |
|----------|------------|
| data-engineer vs backend-engineer (store.py / api routes) | data-engineer owns base.py/store.py/ingest.py schema + persistence; backend-engineer owns config.py/main.py/api routers/DI wiring — the marketplace store is consumed via deps.py, never imported directly by routes |
| data-engineer vs ai-engineer (embedding storage) | data-engineer owns the embedding TABLE + BLOB codec; ai-engineer owns the EmbeddingProvider protocol + mock hash + search cosine math; the store's put/get_vector is the seam |
| ai-engineer vs backend-engineer (factory/config) | backend-engineer owns config surface + factory wiring in main.py; ai-engineer owns provider implementations (openai_compat.py + mock.py) |
| frontend-engineer vs data-engineer (wire types) | data-engineer owns packages/types marketplace shapes (dual-schema sync); frontend-engineer consumes them in engine-client + shells; wire contract = pydantic contracts in marketplace/base.py |
| security-auditor vs backend-engineer (admin routes) | backend-engineer implements require_admin; security-auditor reviews fail-closed behavior + may patch security defects directly in api/marketplace_admin.py + resume.py (its territory) |
| backend-engineer vs frontend-engineer (engine-client.ts) | backend-engineer owns the API contract shapes the client consumes; frontend-engineer owns engine-client.ts itself (the placements section) — the existing identity/marketplace/billing/employer sections are frozen contracts neither side may re-shape; the new wire types land in packages/types/placement.ts (data-engineer dual-schema-syncs, lead-developer reviews the pins) |
| backend-engineer vs security-auditor (api routes) | backend-engineer implements the routes/deps/gates; security-auditor reviews and may patch security DEFECTS directly in api/placements_admin.py + placements/store.py (its territory) — implementation vs review stays with backend |
| backend-engineer vs data-engineer (placements/store.py) | data-engineer owns the schema + store methods (rows, chain, canonical serialization); backend-engineer owns routes/deps/wiring that compose it; the transition_application METHOD contract is data territory (the transaction + hash append), the routes calling it are backend territory |
| backend-engineer vs data-engineer (application.status column) | the placements store is the ONLY post-creation status writer (R-78) — marketplace/store.py's insert_application writes it once at creation; no route or future store adds a second writer outside the one-transaction discipline |
| frontend-engineer vs security-auditor (PII surfaces) | frontend-engineer implements the application surfaces (learner_id visible on own applicants); security-auditor reviews the G-319 pin split (counts-only stays on own-postings lists) and pins the cross-entity 403 negative arms; neither patches the other's files — findings flow through lead-developer |
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
+157 -216
View File
@@ -1,257 +1,198 @@
# Nextcraft — PLAN.md (v0.7 Test Harness Review)
# Nextcraft — PLAN.md (v0.11 CIRR-Style Placement Tracking and Audit)
## Overview
**Milestone:** v0.7 — Test Harness Review (NFR refinement-only): review the entire test harness, make the right tests run effectively, cut tests that provide no real value, eliminate all warnings, and complete the lint/type toolchain — all within this single milestone.
**Tag line:** phase tags advance the v0.6.x line (P0 → v0.6.0 … P6 → v0.6.6 = milestone release), continuing from the v0.5.7 hotfix
**Branch:** milestone/v0.7-test-harness-review
**Milestone:** v0.11 — CIRR-Style Placement Tracking and Audit (Feature, D-098): the machinery that makes the guiding outcome measurable and auditable — the placement record model (the 9th D-027 store: `PlacementRow` with the partial-unique one-active-placement-per-application guard + the append-only `PlacementEventRow` ledger, one global sha256 hash chain with a `UNIQUE(prev_hash)` fork backstop), the employer-side application state machine extending the v0.6 `submitted`-only status (the forward-only D-099 matrix, per-target employer routes under `/v1/employers/applications`, learner withdraw, the operator lane incl. revoke + the R-80 re-hire), placement reporting (distinct-learner counts cumulative toward ≥1,000, per employer, per start-date cohort month), and the audit-grade export (self-contained JSON + human summary generated from the same document, methodology stating its own three pilot gaps, re-export determinism). Mock-first doctrine holds: no third-party auditor integration (the export IS the deliverable), tests never call a cloud, zero new dependencies (D-112).
**Tag line:** phase tags advance the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release), continuing from the v0.10 milestone release (v0.9.6)
**Branch:** milestone/v0.11-placement-tracking (phase work on phase/NN-* branches; Phase 0 on phase/00-pre-execution)
**Phase list (requirement mapping):**
| Phase | Requirement | Focus |
|-------|-------------|-------|
| P1 | REQ-7-001 | Harness wiring: coverage, timeouts, markers, turbo/scripts, leak fixes |
| P2 | REQ-7-002 | Suite composition: evidence-based cuts + infra consolidation (D-057/D-060) |
| P3 | REQ-7-003 | Warning elimination: session.exec(), resource cleanup, filterwarnings=error |
| P4 | REQ-7-004 | Lint & suppression hygiene: ESLint everywhere, RUF100, dead code, version single-source |
| P5 | REQ-7-005 | Type & dependency hardening: mypy relaxed-first, recharts 2→3 |
| P6 | — | Final review + audit (NFR purity gate) + ship |
| P1 | REQ-11-001 | Placement store + evidence ledger: placements/ module (PlacementRow + PlacementEventRow global hash chain, partial UNIQUE active guard), the D-099 matrix + event vocabulary in base.py, the ONE transactional transition_application (flush-then-hash-then-commit), main.py wiring — zero web changes |
| P2 | REQ-11-002 | Application state machine: employer per-target routes (G-311 gate order: 401 → 403-claim → 403-cross-entity → 409), learner withdraw (D-043 stack), placements_admin operator lane (generic transition + revoke + the R-80 re-hire), G-319 pin split |
| P3 | REQ-11-003 | Placement reporting: admin overview (distinct learners, by_employer, by_cohort_month), per-employer placement view, placement.ts wire types + engine-client placements section, /employer/applications page + overview placements section replacing the mock block |
| P4 | REQ-11-004 | Audit export: the self-contained JSON + summary-from-same-JSON, methodology with the three pilot gaps, determinism rules, admin web placements block + export links, the full-chain E2E proof |
| P5 | REQ-11-005 | Learner visibility: GET application-status read, job-detail status panel + placement confirmation, badge honesty |
| P6 | — | Final review + audit + milestone ship (v0.10.6 = milestone release) |
**Wave order (D-062, binding): wiring before composition before elimination before hygiene before hardening.** P1 harness wiring MUST precede P2 suite composition because the cut/consolidate decisions are evidence-based — P1's coverage data tells P2 what to cut. P3 warning elimination is independent of P1/P2 but ordered after them to avoid rebasing `session.exec()` conversions over moved test files. P4 lint hygiene after P3 so the eslint-disable resolutions happen under a warning-clean tree. P5 type+dependency hardening last (mypy adoption and recharts 3 are the riskiest changes — they run against a fully-stabilized harness, and recharts gets the fresh coverage/ESLint net as its safety net). P6 final review + ship.
**Wave order (binding): ledger before machine before reporting before export before visibility.** P1 builds the store + chain every later phase composes (P2's transitions append to it in one transaction; P3's counts read the rows it writes; P4's export embeds the chain it maintains; P5's panel renders the events it records). P2 precedes P3 because reporting counts placements that only the hired transition creates — a report over an un-exercised machine proves nothing. P3 precedes P4 because the export's summary block is the D-107 computation (one truth source — the summary is generated from the same JSON) and the admin web block consumes both the overview and the export. P4 precedes P5 by requirements priority (REQ-11-004 critical ships before REQ-11-005 medium; no dependency runs the other way — the learner panel composes only P1/P2 machinery). Each phase is a vertical slice: store/schema → routes → web wiring → tests green in the same wave → ship.
**Research decisions D-052..D-062 are binding design contracts.** This plan operationalizes them; it does not re-litigate them. Baseline census (research conclusions 44-50) is resolved fact.
**Binding contracts:** clarify decisions D-099..D-112 (PROJECT.md) and research conclusions R-67..R-80 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates. Module layout is decided: `ai_service/placements/{__init__,base,store}.py` — NO service.py (D-112/R-74 — routes compose the store via `app.state` DI); routes: the applications section of api/employers.py, learner routes in api/marketplace.py, `api/placements_admin.py`; `tests/placements/`; `packages/types/placement.ts`.
**Environment facts (unchanged):** python 3.11.2 (venv at apps/ai-service/.venv, pip-only — A-009), node v24.15.0, pnpm 12.3.4, SQLite shared `Settings().db_path` (`~/.nextcraft/data/nextcraft.db`), tests NEVER call the cloud (mock providers + httpx MockTransport), secrets env-only (D-014), runtime state under `~/.nextcraft/` (D-039).
**Environment facts (probe-verified, R-67..R-80):** the hash covers the row's own autoincrement id → the append must be flush-then-hash-then-commit inside one Session, ALL appends funneling through the one transition_application (R-70; decide_claim is the all-or-nothing template employers/store.py:600-652; the ledger must have NO delete path — INTEGER PRIMARY KEY reuses max+1 if the top row is deleted; verification is O(n) sha256 over tiny rows); the matrix precedent is billing's `_VALID_TRANSITIONS` with empty-tuple terminals (billing/store.py:84-90; the 409-matrix test trio tests/billing/test_store.py:400-418; zero migration — existing rows are all `submitted`, marketplace/store.py:211); the shared-DB multi-engine topology is shipped (8 stores, identical pragmas; global SQLModel metadata means create_all on any engine creates every registered table, employers/store.py:400; cross-module import is family-sanctioned employers/base.py:39; NO cross-family foreign keys — G-302 ID references at read); export determinism needs pinned orderings + total generated_at exclusion + float absence (R-73; the list_postings ordering discipline marketplace/store.py:940-948); main.py wiring sites verified (lifespan getattr-guard 182-190/195-200, close() in the finally 335-336, routers beside the employers block 400-411, CORS already carries Authorization 357, G-11 admin boot warning 251-256, deps.py:98); gates verified (require_employer_session employers.py:173-191, _require_claimed :467-484, _own_posting_or_404 :487-503, per-target route idiom :635/:687, provenance `"account:{id}"` :792; learner stack marketplace.py:46-52 → :358 → :359 with window :66-76, `_APPLY_TIMES` :58, cap :63; operator lane employers_admin.py:47 + OPERATOR :54, guard marketplace_admin.py:62-85); web rails verified (engine-client sections — SessionRequiredError :126-131, ClaimRequiredError :136, parseError :146, placements section appends after employer billing :1114; pins.test.ts employer dual-schema block :267-374 + G-319 block :376+; the four surfaces — employer-overview-client.tsx :162/:190-230 real vs mock block :335-372 labelled :351, job-detail-client.tsx apply :95-115 + EngineError 409/404 :103-105 + confirmation :253, moderation page :3/:35 → employer-network-panel.tsx :56 + adminToken/locked-state :28-40); test templates verified (make_client state-injection tests/marketplace/conftest.py:80-120; conftest resets :123-131 + tests/employers/conftest.py:31-33; partial-index sqlite_master pin test_claims_store.py:75-88; gate pins test_postings_write.py:225-234/:315 + test_marketplace_api.py:188; counts-only pin test_postings_read.py:216); two WAL facts (insert_application :873-901 never touches status post-creation; the tamper test's second engine sees the mutation only after commit); three clocks, three serialization rules (start_date date-only ISO YYYY-MM-DD with cohort month `strftime("%Y-%m")`; created_at naive-stored re-labeled via `_as_utc` marketplace/store.py:263-267 — `isoformat()` emits +00:00 for aware, nothing for naive, so the chain pins ONE normalization; generated_at export-only; learner_id is an opaque G-5 handle the 422 scrubber's pii_fields main.py:439 never sees).
**Baseline (probe-verified census, 2026-09-26, counts re-verified at P1 execution):** 785 tests all green — pytest 670 (64 files; census counted 671 — collection at P1 execution says 670, the correct number), web node:test 53 (7 files), CLI node:test 64 (11 files; census counted 61 — the v0.5.7 hotfix `cda6ab1` landed 3 deploy-stale-checkout tests on census day, after the count was taken). Zero coverage tooling. Zero framework timeouts (ad-hoc 20-30s polling deadlines + a 600s CLI bootstrap step). Zero markers despite a real three-tier split (fast-unit / real-uvicorn / real-userns). Web tests OUTSIDE the turbo pipeline. 1,716 warnings per pytest run. 81 inert `# noqa`, 5 orphaned eslint-disable comments, no ESLint at all (turbo lint is a no-op for 5 of 6 packages), 27 inert `# type: ignore`, no Python type checker, recharts@2.15.4 deprecated direct dep, version drift (hardcoded `FastAPI(version="0.3.0")` vs pyproject "0.2.0" vs shipped line).
**Verification commands (phase gates — the repo's own scripts):**
**Count discipline (binding, Task 1-2-05):** test counts shift when tests are cut (P2), env-gated (P1), or added; a count is a point-in-time fact. Every phase's ship notes and verification block MUST restate the CURRENT counts, measured at execution — never copy a stale number forward from an earlier phase. Where a count changes, the ship notes state the mechanism (e.g. "CLI 64 = 63 pass + 1 env-gated E2E skip").
- `pnpm lint` (turbo lint — eslint across all TS workspaces) and `pnpm ai:lint` (ruff on ai-service)
- `pnpm typecheck` (turbo typecheck — tsc workspaces) and `pnpm ai:typecheck` (mypy — zero new overrides; stdlib-only, D-112 keeps the D-086 regime intact)
- `pnpm ai:test:fast` (FAST=1 — `-m "not real_server and not userns"` per D-063; tests/placements/ joins the fast lane)
- `pnpm web:test` (node:test incl. pins.test.ts) + `pnpm cli:test` (unchanged surface, regression guard)
- `pnpm --filter web build` (static export, D-039 unchanged) on web-touching phases (P3, P4, P5)
**Verification hooks (every phase, release-blocking) — count discipline: each phase's ship MUST restate the current test counts (they shift after P2 cuts); the counts below are the P1 baseline.**
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py/main.py wiring, tests/placements/, packages/types/placement.ts, the employer/job-detail/admin web surfaces.
- `pnpm ai:test` — full pytest run (670 at baseline; fast subset via `pnpm ai:test:fast` = `-m "not real_server and not userns"` = 621 tests, 49 deselected; measured at P1 execution: ~56s fast / ~174s full)
- `pnpm web:test` — web `tsx --test` (53 at baseline); `pnpm web:test:cov` = the same run under c8 (coverage is additive, default `test` script unchanged); inside the turbo pipeline (`pnpm test` runs all three workspaces)
- `pnpm cli:test` — CLI node:test (64 at baseline: 63 pass + 1 visible env-gated E2E skip; census counted 61 pre-v0.5.7-hotfix — the deploy-stale-checkout tests `cda6ab1` landed after the census); `pnpm cli:test:cov` = the same run under c8; `pnpm cli:test:e2e` = opt-in real 900s path
- Coverage reports — pytest-cov report (ai-service) + c8 reporters (web/cli), exposed as script targets
- `pnpm ai:lint` — ruff check (RUF100 joins the select set in P4)
- `pnpm typecheck` — 5 tsc projects; mypy joins via `ai:typecheck` in P5
- `pnpm build` — static export emits
- **Hermeticity rule (unchanged):** every test pins `Settings(db_path=tmp_path/...)` — never `~/.nextcraft`, never the cloud.
**NFR purity gate (D-001, enforced at P6):** zero `feat:` commits on the milestone branch. Every change is test-infra, tooling-config, cleanup, or docs. Any work that requires new product behavior is out of scope by definition.
**Secrets policy (binding):** no new secrets this milestone — AI_ADMIN_TOKEN stays env-only (D-014); the placements admin lane inherits require_admin's fail-closed behavior + the G-11 boot warning free (R-74); the export carries no credentials and no identity PII (learner_id is an opaque allowlisted handle, R-79 — name/email/DOB are never stored, identity/base.py:25-33); tests use tmp_path DBs with no credential values; no secret appears in any committed file, log line, or release asset.
---
## Phase 1: Harness Wiring (REQ-7-001)
## Phase 1: Placement Store + Evidence Ledger (REQ-11-001)
**Goal:** Coverage measured in all three workspaces, framework timeouts bounding every test, the three-tier marker split real and strict, web tests inside the turbo pipeline, the CLI E2E opt-in, and the known leaks/silent-skips fixed. P1's coverage output is the evidence base P2 cuts from.
**Goal:** The placements module exists and is proven: the 2-table 9th D-027 store (immutable-at-creation PlacementRow + the append-only global hash-chain PlacementEventRow), the D-099 matrix + event vocabulary in base.py, the ONE transactional transition_application with flush-then-hash-then-commit, main.py wiring — tamper-tested, fork-tested, zero web changes, zero new dependencies.
### Wave 1-1: coverage + timeout + markers (backend-engineer)
### Wave 1-1: Module core — contracts + matrix + schema (backend-engineer + data-engineer, sequential)
- **Task 1-1-01** (backend-engineer): pytest-cov added to dev deps + `[tool.coverage]` config in `apps/ai-service/pyproject.toml` (source = `ai_service`, sensible branch/report settings); `scripts/test.sh` gains a coverage flag; `pnpm ai:test` produces a coverage report every run. c8 for web/cli around the existing `tsx --test` (D-052 — no runner swap, no vitest): c8 devDep in `apps/web` + `apps/cli`, wired into their test scripts (or a `test:cov` script), reporters configured per workspace.
- **Task 1-1-02** (backend-engineer): markers `fast` / `real_server` / `userns` registered in `[tool.pytest.ini_options]` under `--strict-markers` (D-053). **Unmarked tests default to fast** — zero churn on ~600 unit tests. Mark `real_server` on the real-uvicorn suites: `tests/api/test_e2e_credential_flow.py`, `tests/api/test_telemetry_ingest.py`, `tests/telemetry/test_durability.py`, `tests/sandbox/test_telemetry_wiring.py`, `tests/variants/test_environments.py` (+ any suite the census's definition catches at execution — a real uvicorn/process spin-up). Mark `userns` on the real-namespace suites: `tests/sandbox/test_isolation.py`, `tests/sandbox/test_resource_limits.py`, the sandbox-agent tests (`tests/sandbox/test_sandbox_agent.py`). Canonical fast run = `-m "not real_server and not userns"` aliased as `ai:test:fast` (root script + `scripts/test.sh` flag). **Grill G-105:** marker assignment is verified against each suite's actual mechanism at execution — real_server iff the test spins a real uvicorn/process, userns iff it enters a real unshare namespace. Census correction: `tests/sandbox/test_sandbox_agent.py` requires NO namespace (its docstring: plain stdlib process, in-process threads) — it gets NO tier marker; its 20s-deadline outlier (:542) gets `@pytest.mark.timeout` per D-054's outlier mechanism. P1 records a timing baseline (full-run + fast-subset wall times at the 670/53/64 counts) in the ship notes — measured numbers replace "seconds-scale" claims (MH-M2's <60s stays the ceiling). The hang probe proves BOTH tiers: a fast probe dies at 60s AND a marked probe dies at 180s — the collection-hook override is proven, not assumed.
- **Task 1-1-03** (backend-engineer): pytest-timeout — `signal` method, **60s global** (comfortably above the 20-30s polling loops), **180s override for `real_server`/`userns`-marked tests via a conftest collection hook** (D-054); `@pytest.mark.timeout` available for known outliers. Linux-only target makes SIGALRM safe and accurate; the thread method can't cancel blocked calls.
- **Task 1-1-04** (backend-engineer): CLI integration-suite split — the install-script/daemon/doctor.integration CLI suites join the slow lane the same opt-in way as the E2E (mechanism per execution: env-gate or script file-selection), keeping default `cli:test` fast; the fresh-clone E2E is definitively env-gated (Task 1-2-02). **EXECUTION DECISION (P1):** measured the three suites at 6.8s combined — default `cli:test` totals 7.0s; no split required (recorded per the task's own mechanism clause). The E2E alone is env-gated; the rest stay in the default lane.
- **Task 1-1-01** (backend-engineer): `ai_service/placements/__init__.py` + `base.py` — pydantic contracts + constants: `TRANSITION_MATRIX` per D-099 (snake_case `submitted → in_review → interview → offer → hired|rejected|withdrawn`, forward-only with skip-forward — any non-terminal may move to any LATER stage or any terminal, terminals have empty-tuple outgoing edges, the billing/store.py:84-90 shape); `APPLICATION_STATUSES`; actor vocabulary `employer|learner|operator|system` (the `_BILLING_EVENT_KINDS` shape billing/store.py:92, D-101); event-name vocabulary per D-101 + **R-80's placement lane**: application-stage events (NO backfill — `application.submitted` is never fabricated for v0.6 rows, D-101/A-304; one event per transition: in_review/interview/offer/rejected/withdrawn/hired) + placement-level events (`placement.created` on hired, `placement.revoked` on the D-102 correction, `placement.rehired` on the R-80 operator re-hire); `PlacementRead`/`PlacementEventRead`/`EmployerApplicationSummary` response contracts (dual-schema-pinned P3 per D-110); `HireTransitionRequest {role_title, start_date}` (D-103: start_date is a pydantic `date` — never a datetime, future + past allowed, timezone-free by construction; **G-324: role_title = Field(min_length=1, max_length=200) — empty-string → 422, the house Field shape marketplace/base.py:68-71**); `GENESIS_HASH = "0" * 64` module constant (D-101, test-pinned). No service.py — routes compose the store via app.state DI (D-112).
- **Task 1-1-02** (data-engineer): `placements/store.py` — the 9th D-027 store (D-112/R-72): tables `placement` (id PK, learner_id, employer_id, posting_id, application_id indexed, role_title, start_date stored ISO `YYYY-MM-DD` — a calendar date, no datetime coercion in its path, R-79; status `active|revoked`, created_at, decided_at) + `placement_event` (auto-increment id PK, application_id nullable+indexed, placement_id nullable, event_type, actor_type, actor_ref, payload JSON, prev_hash **UNIQUE**, hash, created_at). Bare `placement` is FREE (D-095's prefix rule was collision-driven — marketplace owns posting/employer/application, no placement table exists). D-102 semantics: role_title/start_date/decided_at/learner_id/employer_id/posting_id/application_id are set once at the hired transition and NEVER rewritten (a silent rewrite is exactly the tamper the chain detects); `status` is the ONLY mutable column with exactly one transition `active → revoked`; decided_at = the hired transition's store-stamped created_at (`datetime.now(UTC)`); the one-active-per-application guard is a **partial UNIQUE index `uq_active_placement_per_application` ON application_id WHERE status='active'** (the G-316 `uq_pending_claim_per_entity` shape, employers/store.py:161-167 — revoked rows stay in history; a re-hire after a mistaken revoke creates a fresh row). Mechanics mirror employers/store.py exactly (D-112): `_sqlite_connect` pragmas WAL / synchronous=NORMAL / busy_timeout=5000 / foreign_keys=ON, `sa_type` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` re-labeling, Protocol `PlacementStore` + `SQLitePlacementStore` pair, surfaced IntegrityError, `_constraint_error` helper; global-metadata `create_all` idempotent (checkfirst adds both tables to every existing DB at next boot — the shipped D-095 behavior, employers/store.py:398-400); NO cross-family foreign keys (employer/learner/posting/application are ID references resolved at read/export, G-302); **NO delete method and no DELETE SQL anywhere in the module** (R-70: deleting the top row makes INTEGER PRIMARY KEY reuse max+1 — chain-integrity hole; source-pinned). Canonical serialization per D-101/R-69/R-79: `_canonical_event_json` = `json.dumps({...}, sort_keys=True, separators=(",", ":"))` with created_at normalized via **always `_as_utc(dt).isoformat()` — ONE pinned normalization** (the R-79 pitfall: isoformat emits `+00:00` for aware, nothing for naive — drift between the hashing and reading paths breaks the chain on first re-read) and **no floats in payload ever** (str/int/bool/None only, R-69); `hash = hashlib.sha256` hexdigest over the canonical JSON + prev_hash. Tests `tests/placements/test_store.py`: GENESIS_HASH pinned; table existence via sqlite_master (2 tables) + the partial-index SQL pinned (the test_claims_store.py:75-88 template); pragma pins; duplicate prev_hash → IntegrityError surfaced; no-delete-path source pin; tz round-trip (naive stored, aware read, canonical form stable).
- **Task 1-1-03** (data-engineer): the chain append + `transition_application` — D-100/R-70: `transition_application(application_id, to_status, actor_type, actor_ref, role_title=None, start_date=None)` — ONE Session on the placements engine covering both tables (R-72: the marketplace `application` row is written through it — the sanctioned D-100 deliberate exception; both tables + the status write land in ONE commit, the decide_claim all-or-nothing precedent employers/store.py:634-652 — the two-commit alternative's crash window voids the tamper-evidence the milestone sells): get_application (marketplace/store.py gains ONLY this point read, the get_posting shape :578-584 — insert_application and every other marketplace seam unchanged) → matrix validation (module InvalidTransition → 409 at routes) → update `application.status` → read chain head (`ORDER BY id DESC LIMIT 1`) → construct the event row with prev_hash=head → **`session.flush()` to obtain the autoincrement id → compute hash → set the column → on hired: create the PlacementRow (decided_at = this transition's created_at; role_title/start_date REQUIRED, D-103) + append `placement.created` with placement_id set → `session.commit()`** — flush-then-hash-then-commit, and ALL ledger appends funnel through this one method (a second append path races prev_hash; the UNIQUE backstop catches it as a lost event). Column ownership stated in the module docstring (R-78): marketplace insert_application (:873-901) writes status once at creation; the placements store is the ONLY post-creation writer of application.status — no route or future store adds a second writer outside the one-transaction discipline. Store methods: `verify_chain` (recompute the walk from GENESIS_HASH → head; O(n) sha256 over tiny rows — milliseconds at pilot scale, R-70), `get_placement`, `get_application_events` (id-ordered), `list_active_placements`, `revoke_placement` (the one status-only mutation + `placement.revoked` event, one transaction, operator-only by route), `rehire_placement` (R-80: fresh PlacementRow + `placement.rehired` event WITHOUT touching the application's terminal status — revoked rows don't match the partial WHERE, the exact G-316 property employers/store.py:159-167). Tests: chain-walk verification green after N appends; hired → PlacementRow + both events chained (application_id event + placement_id event); **tamper test** — direct row mutation via a second engine on the same file (mutation commits before the verification walk reads, R-78 WAL snapshot isolation) → verify_chain fails; **fork test** — a second row claiming the same prev_hash → IntegrityError surfaced; **concurrent-transition arm** (the test_concurrent_consumes_one_post_left shape, tests/billing/test_store.py:335): two sessions transition the same application → one winner commits, the loser surfaces IntegrityError/InvalidTransition — never two events on one prev_hash; revoke → status revoked + event; rehire after revoke → fresh active row + three-event ledger sequence (hired, placement.revoked, placement.rehired).
### Wave 1-2: turbo/scripts wiring + leak fixes (frontend-engineer + backend-engineer)
### Wave 1-2: main.py wiring + deps + conftest (backend-engineer)
- **Task 1-2-01** (frontend-engineer): `turbo.json` gains the web test task (`web#test` — package name is `web`; `cache:false`, `outputs:[]` mirroring `cli#test`); root `package.json` gains `test` (turbo test across ai-service + web + cli) and `web:test` (pnpm --filter web test) scripts. Web tests are now inside the pipeline `pnpm test` runs.
- **Task 1-2-02** (frontend-engineer): CLI fresh-clone E2E env-gated per D-055 — the 600s real bootstrap is KEPT (the real path is the value), excluded from default `cli:test` unless `CLI_E2E=1`, exposed as `cli:test:e2e` with a 900s per-test budget (600 + slack).
- **Task 1-2-03** (backend-engineer): conftest leak fixes — `tests/conftest.py:80` mutates `os.environ["AI_PROVIDER"]` without restore: fix via monkeypatch (or explicit restore in teardown) so no test run leaks provider state into the process env; `seed_verified_identity`'s blanket `except Exception: pass` (conftest.py:74-75) narrowed to the expected idempotency exception type (or logged) — a swallowed seeding failure must never masquerade as a seeded store.
- **Task 1-2-04** (frontend-engineer): path-invocation silent skip fixed — `apps/cli/tests/path-invocation.test.ts:14` returns early (passes silently when the binary is missing): report `t.skip` properly so the skip is visible in the run summary, never a silent green.
- **Task 1-2-05** (lead-developer): PLAN.md verification counts corrected and the count-discipline rule documented — every phase's verification block states the current counts (P1 baseline: 670 py / 53 web / 64 CLI; the census already caught this plan file drifting once — never again).
- **Task 1-2-01** (backend-engineer): wiring per D-112/R-74: `deps.py` `get_placement_store` beside get_employer_store (deps.py:98); main.py lifespan getattr-guard `placement_store` directly after the employers block (main.py:195-200 template) + `close()` in the finally block (main.py:336); CORS already carries Authorization (main.py:357 — no change); the G-11 admin boot warning (main.py:251-256) covers the placements admin free. Zero new config knobs, zero .env.example changes (no new throttle — D-104 rides the existing window; the router file + mounts land with its first routes in P2, the main.py:400-411 pattern). `tests/placements/conftest.py` NOW (D-111/G-313/open-risk-4): the make_client state-injection fixture (the tests/marketplace/conftest.py:80-120 shape — tmp_path DB, create_app, `app.state.placement_store` injected, hermetic zero-network by construction — no MockTransport needed, no external calls exist) + the **autouse `_APPLY_TIMES` reset (tests/marketplace/conftest.py:123-131 pattern) before/after EVERY placements test — the withdraw route rides that limiter in P2; without the reset the suite self-429s (the G-313 lesson applied before it ships)**.
**Verification hooks P1:** all suites green at current counts (670 py / 53 web / 64 CLI = 63 pass + 1 visible E2E skip); `pnpm ai:test:fast` completes seconds-scale (measured ~56s); coverage reports produced in all three workspaces; a deliberately-hanging probe test proves the 60s timeout fires (then removed); `pnpm test` (turbo) runs web+cli+ai; `pnpm typecheck` (5 tsc) + `pnpm ai:lint` + `pnpm build` green. Warnings still present — they die in P3.
### Wave 1-3: P1 security review + phase gate (security-auditor + lead-developer)
- **Task 1-3-01** (security-auditor): ledger review (P1 scope, D-111's "an audit ledger nobody has tried to break is theater"): canonical serialization pinned to ONE normalization (the hashing/reading drift pitfall caught by tamper tests), GENESIS pinned, UNIQUE(prev_hash) fork backstop real (IntegrityError surfaced never swallowed), flush-then-hash-then-commit with a single append funnel (no second writer), no-delete-path pinned, partial-unique active-placement guard emitted by create_all (G-316 shape), actor vocabulary closed, no floats in payload, no PII in payload fields. Findings recorded; P0s fixed in-phase.
- **Task 1-3-02** (lead-developer): P1 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` (zero new mypy overrides — stdlib only) + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (additive module — marketplace/billing/employers suites untouched); the D-100 discipline check (one transition_application, no second status writer, no second append path); test counts recorded.
## Phase 2: Application State Machine (REQ-11-002)
**Goal:** The hiring pipeline is live: employers drive per-target transitions on their own claimed-entity applications (G-311 gate order, audit-logged, ledger-chained in the same transaction), learners withdraw their own, the operator lane transitions/revoke/re-hire with provenance — every matrix arm tested, the G-319 pin split enforced.
### Wave 2-1: Employer application routes + G-319 pin split (backend-engineer)
- **Task 2-1-01** (backend-engineer): api/employers.py gains `GET /v1/employers/applications?status=` (own claimed-entity pipeline — session → claimed → applications resolved through own postings via a new in-pattern marketplace grouped read, the count_applications_by_posting discipline store.py:915-932, never per-row N+1; the `EmployerApplicationSummary` contract with **learner_id visible per the G-319 supersession, D-105: application-scoped reads expose learner_id ONLY for applications on the session's own claimed-entity postings**) + the per-target action routes `POST /v1/employers/applications/{application_id}/in-review | /interview | /offer | /reject | /hire` (the per-target idiom employers.py:635/:687 — the matrix stays OUT of request bodies; hire body `{role_title, start_date}` per D-103, both REQUIRED; **G-324: role_title min_length=1/max_length=200 — the empty-string 422 arm pinned beside the missing-fields arm**). Gate order G-311 (D-105/R-75): **401** (require_employer_session employers.py:173-191) → **403** claim (_require_claimed :467-484) → **403** cross-entity (fetch application → posting → `posting.employer_id != claimed` — the _own_posting_or_404 shape :487-503 applied through the application's posting) → **409** transition (never a 409 before a 403). Every route calls the ONE transition_application with `actor_type="employer"`, `actor_ref=f"account:{account.id}"` (the :792 provenance shape) + appends an `employer_audit_events` row (the D-089 one-queryable lane). Tests `tests/placements/test_employer_transitions.py`: gate-order pins (no token → 401, unclaimed → 403, foreign application → 403 — never a 409 first; the test_postings_write.py:225-234/315 pin shape); matrix 409 arms (every illegal move: backward `offer → in_review`, terminal-source `hired → anything`/`rejected → anything`/`withdrawn → anything`); skip-forward arms legal (`submitted → hired` fast-track, `submitted → interview`, `in_review → reject` — R-71); hire → 201 + PlacementRow + chained events + audit row; hire without role_title/start_date → honest 422 with the D-103 required-fields detail; **G-324 arm: role_title="" → 422**.
- **Task 2-1-02** (backend-engineer): the **G-319 pin split** (R-77/D-105, binding): the own-postings LIST stays counts-only — tests/employers/test_postings_read.py:216 green UNCHANGED (the v0.10 boundary holds there); learner_id appears ONLY on application-scoped responses for own-posting applications; cross-entity → 403 with NO application data (anti-enumeration preserved). Pins in test_employer_transitions.py: the list response carries no learner identifiers; the application-scoped GET carries learner_id on own postings; a foreign application's transition/list never returns any application payload.
### Wave 2-2: Learner withdraw + operator lane incl. the R-80 re-hire (backend-engineer)
- **Task 2-2-01** (backend-engineer): api/marketplace.py gains `POST /v1/marketplace/applications/{application_id}/withdraw` body `{learner_id}` (D-104): the **D-043 composition unchanged** — `_enforce_allowlist` (marketplace.py:46-52) → `require_verified_age(18)` (:358) → `_check_apply_rate` (:359; window :66-76, `_APPLY_TIMES` :58, cap 5/rolling-60s :63 — the EXISTING window, no new throttle knob); 403 when the application is not the caller's, 409 when terminal (D-099: `withdrawn` is learner- or operator-triggerable, NEVER an employer session — employers reject instead); calls the same transition_application with `actor_type="learner"`; composes the placements store via app.state DI (deps.py). **G-323 (binding): the apply route's idempotent arm gains a terminal-state guard** — a returned application in `withdrawn`/`rejected`/`hired` → 409 `{"reason": "application_terminal", "status": …}` (the posting_not_published 409 shape, marketplace.py:366-373), never a 200 "applied" confirmation; no new application row (the unique key stands); recovery is visibility (G-322's mine-read + status panel), not resurrection. Tests `tests/placements/test_learner_withdraw.py`: unallowlisted → 403, unverified → 403, foreign application → 403, own+submitted → 200 `withdrawn` + ledger event actor learner, terminal → 409; **apply → withdraw → re-apply → 409 application_terminal (never a 200)**; the conftest reset proves no self-429 (5 withdraws inside the window → the 6th 429s honestly, reset between tests).
- **Task 2-2-02** (backend-engineer): `api/placements_admin.py` (D-106): prefix `/v1/placements/admin`, `require_admin` IMPORTED from marketplace_admin (the billing_admin.py:27 import precedent, employers_admin.py:47 — token namespaces cannot collide, D-090); router mounts in main.py beside the employers block (main.py:400-411). Routes: `POST /v1/placements/admin/applications/{application_id}/transition` `{to, role_title?, start_date?}` — the generic operator remediation route validated against the SAME D-099 matrix (role_title/start_date required iff `to=hired`); **G-327 (binding): `to` is a pydantic Literal of the seven D-099 statuses — unknown value → 422 (body validation), matrix-illegal known value → 409 InvalidTransition; the route never sees a status outside the vocabulary (no 500s, no silent acceptance; test arm: garbage `to` → 422)**; `actor_type="operator"`, `OPERATOR = "operator"` route-level provenance (employers_admin.py:54 — no per-operator accounts, D-094); the operator can drive ANY matrix-legal transition including `withdrawn` on a learner's behalf (remediation with provenance, the D-083 lane). `POST /v1/placements/admin/placements/{placement_id}/revoke` (D-102: the one `active → revoked` correction). **THE R-80 RE-HIRE ROUTE (pinned explicitly): `POST /v1/placements/admin/placements/by-application/{application_id}/re-hire` `{role_title, start_date}`** — after the first hire the application is terminal (`hired → hired` is a 409 on every matrix-validated surface — the R-80 coherence gap); the re-hire creates the FRESH PlacementRow + appends `placement.rehired` (actor operator) WITHOUT touching the application's terminal status — the ledger reads hired → revoke → rehire as three events, never fabricating a second application transition (the rejected matrix-amendment and revoke+reapplication shapes stay rejected per R-80); structurally safe because revoked rows don't match the partial WHERE status='active' (the exact G-316 property). **G-326 (binding): the re-hired PlacementRow's `decided_at` = the re-hire event's store-stamped created_at (D-102's `datetime.now(UTC)` stamp rule extended) — stated in the module docstring + asserted in the revoke→re-hire test arm.** Reporting/export reads land P3/P4. Tests `tests/placements/test_admin_lane.py`: fail-closed 403 on every route (no/missing/invalid token — the R-56 matrix); generic transition every legal arm + the 409 arms + **garbage `to` → 422 (G-327)**; revoke → placement revoked (→ excluded from counts, asserted P3); **revoke → re-hire → fresh active placement + the three-event ledger sequence pinned + decided_at = the re-hire event's stamp (G-326) + second active placement on the same application without revoke → the IntegrityError surfaced as an honest 409**.
### Wave 2-3: P2 security review + phase gate (security-auditor + lead-developer)
- **Task 2-3-01** (security-auditor): gate + PII review (P2 scope): G-311 gate order on every transition route (401 → 403-claim → 403-cross-entity → 409, pinned); the G-319 supersession (R-79): learner_id is an opaque G-5 handle, application-scoped only, anti-enumeration preserved (cross-entity 403 carries no data); employer sessions can never withdraw (D-099); operator provenance on every admin action incl. re-hire; the audit trail answers every decision (employer_audit_events + ledger actor refs). Findings recorded; P0s fixed in-phase.
- **Task 2-3-02** (lead-developer): P2 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (existing marketplace/employers suites green unmodified); the R-80 coherence review recorded (placements, not applications, are the correction lane — the route shape vs D-099/D-102); test counts recorded.
## Phase 3: Placement Reporting (REQ-11-003)
**Goal:** The guiding outcome is measurable: distinct-learner counts computed from rows at query time (per employer, per start-date cohort month, cumulative toward 1,000), the per-employer placement view live, wire types dual-schema-pinned, the employer surfaces converted (the applications page + the overview placements section replacing the mock block).
### Wave 3-1: Reporting reads (backend-engineer)
- **Task 3-1-01** (backend-engineer): reporting per D-107 — counts computed from rows at query time, NEVER denormalized counters (G-302/G-306): `GET /v1/placements/admin/overview` → `{total_active_placements, distinct_learners_placed, guiding_goal: 1000, by_employer: [{employer_id, count}], by_cohort_month: [{month, count}]}` — **the guiding-outcome metric is DISTINCT LEARNERS with ≥1 active placement, all-time cumulative toward 1,000 (a learner placed twice counts ONCE — the outcome's noun is learners, PROJECT.md line 7); revoked excluded from every count (D-102); cohort month = `strftime("%Y-%m")` over start_date (D-103/R-79 — employment-start month, not hired month, not application month)**; by_employer ordered count desc then employer_id, by_cohort_month month ascending (R-73); joins through existing stores (employer identity via employer_accounts/EmployerRow, posting via postings, learner via application rows); batched grouped reads (the count_applications_by_posting discipline store.py:915-932 — never per-row N+1). Plus `GET /v1/employers/placements` — the per-employer placement view (session-gated; own claimed-entity hired applications with role_title/start_date, honest empty state; 403 + claim-CTA unclaimed; 401 no token) — D-109 surface 2's data source. Tests `tests/placements/test_reporting.py`: distinct-learner rule (one learner, two employers, two placements → distinct_learners_placed=1, total_active_placements=2); cohort bucketing (two starts in one month → one bucket; a future-dated start lands in its own month); revoke exclusion (revoked → gone from every count; a learner whose only placement was revoked → back to 0); per-employer view own-rows-only + cross-entity 403 + unclaimed 403.
### Wave 3-2: Wire types + engine-client placements section (frontend-engineer + data-engineer)
- **Task 3-2-01** (frontend-engineer, data-engineer dual-schema-sync): `packages/types/placement.ts` (D-110 — the fourth types module, the employer.ts precedent): ApplicationStatus union (regex-pinned the D-093 status-union way), Placement, PlacementEvent, EmployerApplicationSummary, transition request/response shapes, HireTransitionRequest, PlacementOverview (+ PlacementExport added with the export shapes in P4) + `export * from './placement'` in index.ts. Dual-schema pins in `apps/web/tests/pins.test.ts` (the employer.ts block :267-374 template): **every field asserted both sides against `ai_service/placements/base.py`** — lead-developer reviews the pins (the G-108 discipline from day one).
- **Task 3-2-02** (frontend-engineer): engine-client.ts **placements section** (D-110 — appended after employer billing :1114; ONE client module, no fork): listEmployerApplications, the employer transition functions (inReview/interview/offer/reject/hire with `{role_title, start_date}`), listEmployerPlacements, getPlacementOverview (learner status/withdraw + export functions land P5/P4 with their routes) — every fetch AbortController-equipped (A-406), 401 → SessionRequiredError REUSE (engine-client.ts:126-131), 403 claim → ClaimRequiredError reuse (:136), **NO new error classes** (403 cross-entity renders from EngineError.status). Tests: web fetch-stub tests (the billing-entitlement.test.ts template) — 401 → SessionRequiredError, token attached on every fetch, transition dispatch with the exact body.
### Wave 3-3: Employer web surfaces (frontend-engineer)
- **Task 3-3-01** (frontend-engineer): D-109 surface 1 — the NEW `/employer/applications` page (beside postings/login/register/billing/talent): the hiring pipeline — own applications with status badges (the seven D-099 states), per-target transition CTAs, the hire form `{role_title, start_date}` (a date input, future dates allowed — D-103), learner_id shown (own applicants, G-319 supersession); honest pending/empty/403-claim states (the A-406 board-client discipline); session-gated shell (SessionRequiredError → honest logged-out state, never a retry loop). Tests: web fetch-stub + source pins (the G-312 discipline — no render harness): loading/error/empty/claim states present in source, status badge per status, hire form dispatches the transition, withdraw NEVER offered to employers (D-099).
- **Task 3-3-02** (frontend-engineer): D-109 surface 2 — the employer overview page's real placements section **REPLACES the mock-labeled placement metrics block** (employer-overview-client.tsx :335-372, the "(mock — no backend computes this)" label :351 — D-092: never leave a convertible surface on mock): hired applications with role_title/start-date via listEmployerPlacements, honest empty state; the talent-match/applicant-pipeline mock metrics STAY mock-labeled (no backend computes them). Tests: pins — the overview grep-clean of the placement mock block + the retained mock labels intact; fetch-stub placements render + empty state; `pnpm --filter web build` (static export) green.
### Wave 3-4: P3 phase gate (lead-developer)
- **Task 3-4-01** (lead-developer): P3 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` (web-touching phase) green; the D-110 pin review verdict recorded (placement.ts ↔ placements/base.py field-for-field); counts-discipline check (no denormalized counters anywhere); test counts recorded.
## Phase 4: Audit Export (REQ-11-004)
**Goal:** The third-party-auditor deliverable exists: the self-contained JSON export + the human summary generated from the same document, methodology stating its own three pilot gaps, byte-deterministic re-exports, the admin web placements block + export links, the full-chain E2E proof.
### Wave 4-1: Export + summary routes + methodology + E2E (backend-engineer)
- **Task 4-1-01** (backend-engineer): `GET /v1/placements/admin/export` (D-108) — one self-contained JSON document via a single store-level builder function: `{schema_version, generated_at, summary: {distinct_learners_placed, total_active_placements, revoked_count, by_cohort_month}, cohort: [placement records with employer/posting/application refs + role_title + start_date], evidence_bundles: [per-placement {posting_record, application_record, transition_history[], decision_provenance} — **G-325 (binding): bundles cover EVERY placement (active AND revoked), each with the full event history incl. placement.revoked/placement.rehired — the correction history is precisely what an auditor most wants to inspect; `cohort` stays active-only; `revoked_count` equals the placement.revoked events (cross-checkable, rides G-321(c))** — each resolved from SOURCE ROWS at export time (G-302: the export EMBEDS resolved source content — snapshot-at-export, which is what self-contained means — while live rows stay the only write path; **NO backfill**: pre-v0.11 `submitted` applications carry only their `created_at` in the bundle — honest absence, never fabricated history, D-101/A-304)], methodology: {placement_definition, counting_rules, verification, scope}, ledger: {events: [{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at, prev_hash, hash}], head_hash}}`. Methodology per R-68 (written from in-tree facts) with **the three R-67 pilot gaps stated explicitly — (a) no minimum-employed-duration check (a placement = a recorded hire, not a 90-day sustained role), (b) no salary (D-098), (c) verification is employer-principal attestation + operator oversight, not third-party employment verification — an audit methodology that omits its own verification limits is A-304-class dishonesty**: `placement_definition` = a PlacementRow created by the hired transition, employer-attested role_title + start_date, active at export time, one active placement per application (the G-316 partial unique); `counting_rules` = distinct learners with ≥1 active placement, all-time cumulative toward 1,000, revoked excluded, cohort month = start_date calendar month (D-107); **`verification` per G-321 (binding): states (a) what the chain proves (in-place mutation/fork detection of an intact sequence), (b) what it does NOT (wholesale DB replacement undetectable by the walk alone — the auditor's defense is export-to-export continuity: a prior export's ledger.events must appear as an exact prefix of a later export's, with the prior head_hash equal to the hash at that prefix position), and (c) the event↔row cross-consistency check (every active placement has placement.created; every revoked has placement.revoked; revoked_count matches the placement.revoked events)**; `scope` = pilot-bounded, every placement in the DB unbounded by date, evidence class = employer-principal attestation; the cohort divergence documented deliberately (employment-start month, not graduation cohort — no graduation events exist in a marketplace, R-68); **CIRR kept as naming, not citation — the text stands on its own definitions (R-67)**. Determinism rules (R-73, binding): events ordered by id (chain order); `by_cohort_month` month ascending; cohort/evidence lists ordered placement `created_at` then id (the list_postings discipline, marketplace/store.py:940-948) — pydantic declaration-order serialization is deterministic but set-derived lists would not be, so every list is built with an explicit sort; **`generated_at` is export-only metadata (a function of the request, not the rows — R-79) and is excluded from determinism comparison**; **float absence — every export value is str/int/bool, no ratios or percentages (counts are integers by construction, D-107)**; `head_hash` = the last event's hash = `verify_chain()`'s computed head. pydantic + `JSONResponse` only — zero new deps (D-112). Tests land with Task 4-1-03.
- **Task 4-1-02** (backend-engineer): `GET /v1/placements/admin/export/summary` (D-108) — `text/markdown` rendered **FROM the same JSON document (one builder, two renderings — the summary can never drift from the export, D-108's anti-drift rule)**: totals table (distinct learners placed, total active placements, revoked count, the guiding-goal progress line toward 1,000), per-cohort month table, the methodology prose (rendered from the export's methodology block — **G-321: incl. the chain's proof scope, its wholesale-rewrite limit, and the export-to-export prefix-continuity anchor**), **the head hash + chain-walk verification instructions** (recompute `hashlib.sha256` over the canonical JSON + prev_hash from GENESIS_HASH event-by-event — the walk is offline: the export embeds every event, so the auditor needs nothing else, R-70); **G-328 (binding): the summary's title carries the attestation language ("employer-attested, operator-overseen") — never a "CIRR-verified" claim**. Tests: summary totals equal the export's summary block on the same DB state (the same-builder pin); content-type pinned; 403 fail-closed without the admin token.
- **Task 4-1-03** (backend-engineer): `tests/placements/test_export.py` — **re-export determinism: two exports on an unchanged DB → identical `ledger.events` chains + identical `head_hash`, with generated_at excluded (parsed field-by-field comparison or key-strip — R-73's TOTAL-exclusion rule)**; **chain-head consistency: `head_hash` = the last event's `hash` = `verify_chain()`'s output** (the walk re-computes every hash from GENESIS_HASH); **methodology completeness: the three R-67 gaps + all four methodology keys asserted present + the G-321 verification-scope statements (proof scope, wholesale-rewrite limit, export-to-export continuity, event↔row cross-check) asserted present**; **evidence-bundle completeness per G-325: every placement (active OR revoked) has a bundle with the resolved posting_record + application_record + transition_history (revoked bundles carry placement.revoked; re-hired carry placement.rehired)**; revoked placements absent from cohort but present in revoked_count (D-108); 403 fail-closed on both routes. Plus **the full-chain E2E proof `tests/placements/test_e2e_audit.py`** (the milestone's machine-checked reason-to-exist, D-111): employer register → claim → admin approve → create draft (direct package grant, the grant_posts precedent test_entitlement_enforcement.py:70-85) → publish → verified learner applies (seed_verified_identity, SUITE_LEARNERS) → employer drives in_review → interview → offer → hire `{role_title, start_date}` → PlacementRow created → ledger events chained (`verify_chain` green) → export → identical chain + head on re-export → overview counts the learner ONCE toward 1,000.
### Wave 4-2: Admin placements block + export links + security review + phase gate (frontend-engineer + security-auditor + lead-developer)
- **Task 4-2-01** (frontend-engineer): D-109 surface 4 — the moderation page (app/(admin)/admin/moderation/page.tsx:3/:35, the EmployerNetworkPanel area employer-network-panel.tsx:56) gains a placements block: aggregate counts (distinct learners placed, total active placements, guiding-goal progress) + per-employer placement lists via `GET /v1/placements/admin/overview`, transported over the existing `adminToken()` G-308 sessionStorage wrapper (engine-client.ts:815; the locked-state :28-40 pattern — **403 renders the honest locked state with the token prompt, never a retry loop**); export links — both fetched via the admin transport (token header attached, AbortController-equipped): **the summary opens in-tab** (the markdown rendered in the panel) and **the JSON export downloads** (blob download from the fetched document); honest empty state at zero placements. **G-328 (binding): counts are labeled as recorded placements ("distinct learners placed", "placements recorded") — "CIRR" NEVER appears as a verification claim on any live surface; "CIRR-style" describes the export format in release notes/methodology only (source pin: no "verified" placement wording on any new surface).** Tests: web fetch-stub — locked state on 403, counts + per-employer lists render, export links dispatch with the admin header, empty state; source pins for the locked-state branches + the G-328 wording pin.
- **Task 4-2-02** (security-auditor): export-integrity review (P4 scope) — determinism rules honored (orderings pinned, generated_at total exclusion, no floats), **no PII beyond opaque handles** (learner_id is a G-5 roster handle, not name/email/DOB — identity PII is never stored, R-79; the export + 422 scrubber surface carry nothing else), methodology honesty (the three gaps stated; attestation never presented as third-party verification — A-304, the never-display-forgeable-as-verified constraint), head-hash truth (the export's head equals verify_chain's), evidence bundles resolved from source rows (G-302), both export gates fail-closed. Findings recorded; P0s fixed in-phase.
- **Task 4-2-03** (lead-developer): P4 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` green (web-touching phase); the determinism + methodology-honesty verdicts recorded; test counts recorded.
## Phase 5: Learner Placement Visibility (REQ-11-005)
**Goal:** The learner sees their outcome on the surface where they applied: current status + full timestamped history + placement confirmation when hired (one read, one truth source), withdraw within reach; the badge stays honest everywhere.
### Wave 5-1: Learner application-status read route (backend-engineer)
- **Task 5-1-01** (backend-engineer): `GET /v1/marketplace/applications/{application_id}?learner_id=` (D-104, in api/marketplace.py — the existing apply surface): gated by the **D-043 composition** (G-5 allowlist marketplace.py:46-52 → verified 18+ :358 — the P2 withdraw additionally rides the existing apply-rate window; no new throttle knob, D-088 untouched); returns `{application, events[], placement|null}` — current status + the full id-ordered timestamped ledger history for THIS application (scoped query, never another learner's events) + the placement record when hired (REQ-11-005's outcome visibility + placement confirmation in one read, D-104); composes the placements store via `app.state` DI (deps.py); the response is a named pydantic contract in placements/base.py (pinned in placement.ts per D-110). **G-322 (binding): the mine point-read — store `get_application_by_posting_learner(posting_id, learner_id)` (the insert_application unique key — the get_posting point-read shape, marketplace/store.py:578-584) + route `GET /v1/marketplace/applications/mine?posting_id=&learner_id=` on the unchanged D-043 gate stack, returning `{application_id, status}` or 404 — the panel NEVER depends on client-persisted application_ids; without this route the status panel cannot find the application on revisit (the common case for a job board).** Tests `tests/placements/test_learner_status.py`: own application → 200 with status + events[] (id-ordered, this application only); hired → placement payload with role_title/start_date/employer refs; foreign learner_id → 403; unverified → 403; unallowlisted → 403; unknown application → 404; **mine-read arms (G-322): own → 200 {application_id, status}; none → 404; foreign learner_id → 403; unverified → 403**; the G-313 conftest reset holds (no self-429).
### Wave 5-2: Job-detail status panel (frontend-engineer)
- **Task 5-2-01** (frontend-engineer): D-109 surface 3 — job-detail-client.tsx (the existing apply surface: apply :95-115, EngineError 409/404 discrimination :103-105, confirmation render :253) gains the application-status panel: **G-322 composition: mine-read (`GET /applications/mine?posting_id=&learner_id=`) first → found? render the status panel via D-104's read : not found? render the apply CTA — never client-persisted application_ids**; current status badge (the seven D-099 states), timestamped history rows (events[]), placement confirmation when hired (role title, employer, start date — D-104's read), and the withdraw CTA (the learner's ONLY edit, D-104 — 409 terminal copy honest, and G-323's apply → 409 application_terminal copy renders the honest terminal state); **EngineError.status discrimination through the existing checks — NO new error classes** (403 foreign → the honest "not your application" state; 404 unknown; 401/403 gate arms render their existing states); AbortController everywhere (A-406). Badge honesty (A-304/D-109): **a placement badge renders ONLY in this learner's-own application surface where the data is real; the talent profile pages carry NO badge (v0.1 mock imports — a badge on a mock page is fabricated placement, D-092 binding)**. Tests: web fetch-stub + source pins (the G-312 discipline) — each status state renders, history rows, placement-confirmation payload render, withdraw dispatch + terminal 409 copy, foreign-403 copy, **mine-read composition (mine 404 → apply CTA; mine 200 → status panel)**; pins — talent-profile pages grep-clean of placement-badge additions; pins.test.ts placement blocks green.
### Wave 5-3: P5 phase gate (lead-developer)
- **Task 5-3-01** (lead-developer): P5 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` green (web-touching phase); the A-304 badge-honesty census recorded (no badge on mock talent pages, no fabricated placement anywhere, mock labels retained where no backend computes them); test counts recorded.
## Phase 6: Final Review + Ship (milestone release, v0.10.x line)
**Goal:** Multi-persona review of all v0.11 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.10.6 (final v0.10.x patch = milestone release), full asset matrix + registry image via the existing release lane (no infra changes — the module rides D-064/D-065), milestone completion commits, branch cleanup.
### Wave 6-1: Multi-persona final review (lead-developer + security-auditor)
- **Task 6-1-01** (lead-developer → all active personas): full milestone-branch diff review — the **D-100 one-transaction discipline holds end-to-end** (one transition_application, no second status writer, no second append path — R-78), the **R-80 re-hire coherence verdict** (placements, not applications, are the correction lane; the ledger reads hired → revoke → rehire as three events, never a fabricated second application transition), the **G-319 pin-split verdict** (counts-only stays on the own-postings list — test_postings_read.py:216 unchanged; learner_id application-scoped only), the **D-110 wire-pin review** (placement.ts ↔ placements/base.py field-for-field + the status-union regex), the D-092 honest-surface census (the overview mock block replaced, talent mocks retained + labeled, no fake-converted surface, no convertible surface left on mock). P0s fixed in-phase; P1+ findings dispositioned with recorded follow-ups — never loop back to EXECUTE.
- **Task 6-1-02** (security-auditor): final review — no credentials or identity PII in logs/releases/assets (learner_id is an opaque handle everywhere, R-79); gate orders pinned on every principal class (employer/learner/operator); the ledger's no-delete path intact; tamper/fork/concurrency arms green; export determinism + methodology honesty re-verified; no secret echoed by any exception path.
### Wave 6-2: Audit + ship (lead-developer)
- **Task 6-2-01** (lead-developer → ship): audit (git log ↔ .ciagent/ reconstruction; file discipline — the D-112 addition census: ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py get_placement_store, the marketplace store's get_application point read + grouped reads, main.py wiring, tests/placements/, packages/types/placement.ts + index.ts export, engine-client placements section, /employer/applications page, overview placements section, job-detail panel, admin placements block; branch/commit discipline — feat: scoped to the placement surface) + ship: merge milestone → main; tag v0.10.6; Gitea release with full summary + the complete asset matrix + registry image (the existing tag-triggered lane — zero workflow changes); delete milestone branches (tags preserve history); mark REQ-11-001..005 complete in REQUIREMENTS.md; ROADMAP milestone entry; checkpoint cleared.
---
## Phase 2: Suite Composition (REQ-7-002)
**Goal:** A smaller suite with no loss of regression value — cuts decided by D-057's falsifiable rule against P1's coverage evidence, duplicated harness infra in one `tests/helpers/` package, CWD-fragility and sandbox-droppings fixed, fixed sleeps replaced where hooks exist.
### Wave 2-1: Python consolidation + cuts (backend-engineer)
- **Task 2-1-01** (backend-engineer): `tests/helpers/` package per D-060 — **KillableProxy is TWO helpers, not one (grill G-101)**: `ThreadedKillableProxy` (test_sandbox_agent.py:275 — threaded/sync; kill() severs sockets AND closes the listener: hard-down until resume()) and `AsyncKillableProxy` (test_durability.py:45 — asyncio; kill() keeps the listener, RSTs reconnects: fail-fast; revive() restores). Do NOT unify the semantics — each encodes the outage behavior its suite's assertions depend on; each moves verbatim with import paths adapted only (or stays in place if rename costs more than dedup saves). Also consolidate `_free_port()` (×4), the userns probe (×4), real-uvicorn serve boilerplate (×5 sites → a `serve()` contextmanager), imported as `from tests.helpers...`; fixtures STAY in conftest.py. Zero behavior change — every suite that imports them stays green is the equivalence proof.
- **Task 2-1-02** (backend-engineer): cut `test_budget_constant_defined` (`tests/voice/test_latency.py:58`) — it asserts a literal defined in its own file: the tautology class D-057 removes. Mock-meta tests (`tests/llm/test_mock.py`, `TestMockProvider`) are defensible — the mock is the suite-wide engine — KEEP.
- **Task 2-1-03** (backend-engineer): `tests/sandboxes/` droppings — **grill G-110 factual correction:** they are UNTRACKED and already gitignored (`git ls-files` = 0), not committed. The fix is sweep-at-suite-end + a suite-fixture cleanup + the existing gitignore. Do NOT move the userns suites' sandbox roots under tmp_path — the shim tmpfs-mounts /tmp inside each namespace (test_resource_limits.py:86-91), so in-repo roots are a fabric constraint; document it in the helper docstring.
- **Task 2-1-04** (backend-engineer): deterministic waits where the harness already has hooks (test_telemetry_ingest.py:76 spin; poll loops with condition/event hooks) — replace fixed sleeps with event-driven waits. **Grill G-102 correction:** the "8s was flaky" site (test_sandbox_agent.py:542) is already an event-driven `_wait_until` poll — no fixed sleep exists there; its 20s deadline exists because full-suite load starves the agent's supervisor thread (root cause addressed by the P1 fast-subset split, NOT by wait rewrites). Do not churn a working poll loop hunting for a nonexistent sleep. Timeout-widening stops being the flake strategy now that D-054 bounds exist.
### Wave 2-2: web cuts + CWD fix (frontend-engineer)
- **Task 2-2-01** (frontend-engineer): web source-pin triage per D-057 — **KEEP a pin iff** it encodes a documented regression lesson (E87-class: named after the regression it pins) or verifies cross-boundary wiring invisible to typecheck/build/behavioral tests (runtime fetch wiring, env keys, gate composition, JSON-LD presence); **CUT a pin** whose assertion duplicates another test or merely asserts code says what it says (e.g. "openGraph exists in a file"). Kept pins move to a single `pins.test.ts` naming their regression. **Grill G-103:** expect roughly 8-14 cuts of the 21 pin sites (priors reconciled — neither number binds; the per-assertion triage decides). The recorded per-assertion triage table in the ship notes (assertion → KEEP/CUT → which D-057 clause) is the SOLE cut evidence for pins — coverage delta is inapplicable to source pins (they test source text, not executed code); the P1→P2 coverage-delta check applies to Python cuts only. Cross-boundary pins (TS↔py contract sync, JSON-LD presence, gate composition, fetch wiring) must survive in the triage (G-108).
- **Task 2-2-02** (frontend-engineer): `apps/web/tests/helpers.ts` — `wirePosting()` (×2: marketplace-shells.test.ts, marketplace-search.test.ts) consolidated; web helpers mirror D-060's explicit-import pattern.
- **Task 2-2-03** (frontend-engineer): CWD-fragility fixed — web source-pin tests resolve paths relative to `import.meta.url` (never `process.cwd()`), so they pass from the monorepo root and from `apps/web` alike.
**Verification hooks P2:** all suites green at the post-cut counts (restated in the ship notes — the new baseline P3+ carries); coverage reports re-run — the coverage delta is the cut evidence (nothing meaningful went dark); `pnpm test` green end-to-end; fast subset still seconds-scale; `tests/sandboxes/` empty of droppings at rest; `pnpm typecheck` + `pnpm ai:lint` + `pnpm build` green.
---
## Phase 3: Warning Elimination (REQ-7-003)
**Goal:** pytest full run with ZERO warnings — deprecated API migrated at all 14 sites, every ResourceWarning closed at source, filterwarnings error-first making hygiene self-enforcing.
### Wave 3-1: session.exec() (data-engineer)
- **Task 3-1-01** (data-engineer): `session.query()` → `session.exec()` at all 14 SQLModel-deprecation sites — `marketplace/store.py` ×12 (lines 490-952), `identity/store.py` ×2 (lines 172/189) — its store territory. The full test suite green IS the equivalence proof (every store behavior is pinned by contract tests).
### Wave 3-2: resource cleanup (backend-engineer)
- **Task 3-2-01** (backend-engineer): UploadFile close at the 10 multipart sites (`api/defense.py:214`, `api/marketplace.py:446`) — `await upload.close()` or context-manager per FastAPI docs; the request body is consumed, never left to GC.
- **Task 3-2-02** (backend-engineer): socket close ×3 in `scripts/sandbox-agent.py:559` (the capture agent's sockets close deterministically); file handle in `tests/grading/test_engine.py:428` → `Path.read_text`.
- **Task 3-2-03** (backend-engineer): dead `if False` branch removed (`tests/identity/test_identity.py:191-194`); `fixture_loop_scope` pinned in pytest config (kills the pytest-asyncio per-fixture warning).
- **Task 3-2-04** (security-auditor, phase-specific review): the UploadFile/socket close paths must NOT weaken the PII-redaction and stream-close guarantees (resume bytes never logged/stored; defense audio lifecycle honest) — reviewed against the P4-B sentinel tests before W3.
### Wave 3-3: filterwarnings policy (backend-engineer)
- **Task 3-3-01** (backend-engineer): `filterwarnings = ["error", ...]` per D-056 — base action `error` (new first-party warnings fail the run); a narrow enumerated allowlist for exactly the 2 upstream import-time deprecations (fastapi/starlette TestClient httpx, anyio BlockingPortal), each scoped by exact message + emitting module; **no blanket category ignores, nothing ignored for `ai_service.*`** — first-party ResourceWarnings were fixed at source in W2, never ignored. W3 runs last so the policy flip PROVES the zero-warning state. **Grill G-104 growth rule:** the "exactly 2 upstream ignores" invariant holds at P3 ship; if P5 dependency changes surface NEW upstream deprecations under `filterwarnings=error`, fix at source where possible, otherwise they join the allowlist ONLY as module+message-scoped entries, each justified in the P5 ship notes — category ignores and any `ai_service.*` ignore remain forbidden forever.
**Verification hooks P3:** `pnpm ai:test` full run (current counts, restated) with **ZERO warnings** — `pytest -W error` green, no dedup machinery needed; SQLModel deprecation gone (grep: zero `session.query(` in `ai_service/`); ResourceWarning census sites all closed; `pnpm web:test` + `pnpm cli:test` green; `pnpm typecheck` + `pnpm ai:lint` + `pnpm build` green.
---
## Phase 4: Lint & Suppression Hygiene (REQ-7-004)
**Goal:** `turbo lint` exercises all 6 workspaces; every suppression is enforced-and-meaningful or gone; dead code, stale comments, and version drift eliminated.
### Wave 4-1: ESLint adoption + violation fixes (frontend-engineer)
- **Task 4-1-01** (frontend-engineer): ESLint flat config (`eslint.config.mjs`: typescript-eslint recommended + react-hooks plugin; **minimal viable rule set** — the milestone goal is a working lint gate, not maximal strictness) for `apps/web`, `apps/cli`, `packages/types`, `packages/mock-data`, `packages/ui`; eslint deps added per workspace; `lint` script in each package.json. With ruff on ai-service, `turbo lint` now exercises all 6 workspaces — no more no-op for 5 of 6.
- **Task 4-1-02** (frontend-engineer): fix all violations the new config finds (expected: unused vars, dead imports) and resolve the 5 orphaned eslint-disable comments (remove the comment or honor the suppression — an orphan can no longer hide). **Grill note:** the `jsx-a11y/alt-text` orphan (packages/ui avatar.tsx:48) is NOT covered by the planned minimal config — resolve it by explicit decision (honor: add alt text; or remove with the a11y tradeoff recorded in ship notes), never a silent deletion.
### Wave 4-2: noqa + dead code + version drift + engines (backend-engineer + data-engineer)
- **Task 4-2-01** (backend-engineer): RUF100 reconciliation — audit all 81 inert `# noqa` (suppressed codes not in the select set: SLF001 ×57, ANN001 ×12, E402 ×5 stale-positions, rest scattered); remove every inert one, keep none that aren't enforced; **add RUF100 to the ruff select set going forward** so inert suppressions can never accumulate again.
- **Task 4-2-02** (backend-engineer + data-engineer): dead exports removed — `packages/mock-data/ai-scenarios.ts` module + its re-exports; `NavItem`/`ThemeConfig` types; `Spawn` type; `MicControl` primitive — remove or wire, decided per actual usage at execution; unused is gone, used is kept.
- **Task 4-2-03** (backend-engineer): stale comments fixed — `board-client.tsx:179` "coming in v0.6 search" (search shipped), `sandbox-terminal.tsx:44` "lands in v0.4" (v0.4 passed without it — state the honest current status), pricing page's "deferred beyond v0.6" (reviewed; updated only if stale — payments remain deferred, the framing must not be).
- **Task 4-2-04** (data-engineer): version single-source per D-061 — `pyproject.toml` `version` is the single source, set to the shipped line (the v0.6.x tag line this milestone advances); `FastAPI(version=)` derives from `importlib.metadata.version("nextcraft-ai-service")` (safe fallback for un-installed runs); stale version comments removed in the same sweep — drift becomes structurally impossible.
- **Task 4-2-05** (lead-developer): `engines` fields added — root package.json (node + pnpm); apps' engines match Next's documented node requirement.
**Verification hooks P4:** `pnpm lint` green across all 6 workspaces (turbo lint no longer a no-op anywhere); `pnpm ai:lint` green with RUF100 in the select set and zero inert noqa (count: 0 of the former 81); zero eslint-disable orphans; `pnpm ai:test` still zero-warning at current counts; `pnpm web:test` + `pnpm cli:test` green; `pnpm typecheck` + `pnpm build` green; `FastAPI(version=)` reflects package metadata (pinned by a test).
---
## Phase 5: Type & Dependency Hardening (REQ-7-005)
**Goal:** mypy adopted at a relaxed-first baseline that makes all 27 `# type: ignore` meaningful; recharts on v3 with the chart surfaces proven by build+typecheck+tests.
### Wave 5-1: mypy (backend-engineer)
- **Task 5-1-01** (backend-engineer): mypy adopted for ai-service per D-058 — relaxed-first baseline: `check_untyped_defs=true`, `warn_unused_ignores=true`, `warn_redundant_casts=true`, `no_implicit_optional=true`, `warn_unreachable=true`; **NOT full strict** (per-module `[[tool.mypy.overrides]]` tightening available later). mypy dev dep + `[tool.mypy]` config; `ai:typecheck` script + turbo task (joins `pnpm typecheck`).
- **Task 5-1-02** (backend-engineer): all 27 `# type: ignore` made meaningful — each either REMOVED (the site typechecks clean) or SCOPED with a reason (`# type: ignore[<code>]` with justification); violations fixed only where cheap, otherwise ignore-with-scoped-code. `warn_unused_ignores` makes the inert set self-eliminating.
### Wave 5-2: recharts 2→3 (frontend-engineer)
- **Task 5-2-01** (frontend-engineer): recharts 2→3 (D-049) — dep upgraded to ^3, chart component code fixed for the v3 API. Verification per D-059: `tsc --noEmit` green + `next build` static export green (SSR-renders the chart pages — the real React 19/recharts 3 integration path) + existing web tests green; **no browser/render harness** (new deps = framework addition, out of scope). **Grill G-106:** the live mount probe is owned + sequenced — lead-developer deploys the new build via the existing `nextcraft deploy` fabric AFTER P5 verification, BEFORE the P6 milestone tag, probes the chart pages (mount + render, no console errors), records the result in ship notes; a failed probe holds the release and triggers the documented v2-rollback. The wave is timeboxed: if the v3 fix surface exceeds compatibility shims on the 2 chart files, pin v2 and record the rollback as the P5 outcome — the milestone does not stall on chart cosmetics.
**Verification hooks P5:** `pnpm typecheck` green including mypy (`ai:typecheck` in the turbo graph); every `# type: ignore` scoped-or-gone (count: 0 unscoped); recharts@3 in the dep tree with zero v2 direct deps; `pnpm build` static export emits with chart pages SSR-rendered; all suites green at current counts, zero warnings, coverage reports produced; **any filterwarnings allowlist additions beyond the P3 pair enumerated and justified (G-104)**.
### Wave 5-3: deprecated-transitive audit (backend-engineer)
- **Task 5-3-01** (backend-engineer, grill G-109): after mypy + recharts, audit `pip list` + `pnpm outdated` against the zero-warning regime; resolve flagged deprecated transitives within existing budgets or record "none actionable" in the P5 ship notes — REQ-7-005's "deprecated transitive deps resolved where toolchain allows" clause is falsifiable only through this concrete pass.
---
## Phase 6: Final Review + Ship (milestone release, v0.6.x line)
- **Task 6-1-01** (lead-developer → ci-review personas): multi-persona review of the full milestone branch diff (all phases — harness correctness, cut discipline, security, maintainability); security-auditor confirms the resource-cleanup wave did not weaken PII-redaction/stream-close guarantees; **NFR purity gate (D-001): zero `feat:` commits** — `git log main..milestone/v0.7-test-harness-review` contains no feature commits; any found is a scope-creep P0 fixed in-phase. Scope-creep check: no new product surface, no Gitea Actions CI YAML, no Playwright/browser E2E, no test-framework swap.
- **Task 6-2-01** (ci-audit): reconstruction test (git log ↔ .ciagent/ files), file/branch/commit discipline, tag hygiene; critical fixes in-phase.
- **Task 6-2-02** (lead-developer → ci-ship): merge milestone → main; tag the milestone release (final v0.6.x phase tag); Gitea release with full summary + `nextcraft-linux-x64` + `.sha256` assets (D-036); delete milestone branches; mark REQ-7-001..005 complete; clear checkpoint.
---
## Must-Haves (per phase — concrete, falsifiable, test-linked)
### Phase 1
- **MH-1a**: Coverage measured and reported in all three workspaces — pytest-cov wired with `[tool.coverage]` config; c8 wraps the existing `tsx --test` in web and cli (no runner swap); a coverage report is produced by the standard test commands.
- **MH-1b**: Markers `fast`/`real_server`/`userns` registered under `--strict-markers`, verified per actual mechanism at execution (G-105): real-server suites (test_e2e_credential_flow, test_telemetry_ingest, test_durability, test_telemetry_wiring, test_environments, CLI install-script/daemon/doctor.integration) and userns suites (test_isolation, test_resource_limits; NOT test_sandbox_agent — no namespace, unmarked/fast with `@pytest.mark.timeout` on its 20s outlier); unmarked defaults to fast; `pnpm ai:test:fast` completes within the MEASURED baseline recorded in ship notes (MH-M2 <60s ceiling).
- **MH-1c**: pytest-timeout `signal` method live — 60s global, 180s for marked suites via the conftest collection hook; proven by a deliberate hang probe (removed after the proof).
- **MH-1d**: CLI fresh-clone E2E env-gated (D-055) — default `cli:test` excludes it; `CLI_E2E=1` + `cli:test:e2e` runs the real 600s bootstrap under a 900s budget.
- **MH-1e**: Web tests inside the turbo pipeline — `web#test` task in turbo.json; root `test` + `web:test` scripts; `pnpm test` runs all three workspaces.
- **MH-1f**: Leak/skip hygiene — `AI_PROVIDER` env mutation restored (no post-run env leak, provable), `seed_verified_identity` swallow narrowed to the expected exception (or logged), path-invocation reports `t.skip` visibly (never a silent green), PLAN.md verification counts corrected (670/53/64 baseline) with the per-phase count-discipline rule in force.
### Phase 2
- **MH-2a**: Source-pin triage decided per-assertion by the D-057 rule, recorded in the ship-notes triage table (assertion → KEEP/CUT → clause) — the SOLE cut evidence for pins (G-103); kept pins live in `pins.test.ts` named after their regressions; tautologies cut (~8-14 of 21 expected; final call from the rule); cross-boundary pins survive; `test_budget_constant_defined` gone; the mock-meta tests (the suite-wide engine) kept.
- **MH-2b**: `tests/helpers/` package consolidates KillableProxy, free_port, userns probe, `serve()` contextmanager; `wirePosting` in web `tests/helpers.ts`; zero behavior change — all suites green at the post-cut counts (restated).
- **MH-2c**: Web tests pass from ANY CWD — paths resolved relative to `import.meta.url` (pinned by running the suite from the monorepo root).
- **MH-2d**: `tests/sandboxes/` droppings cleaned + anchored to tmp_path where bind-mount permits (else documented + gitignored); tree clean at rest.
- **MH-2e**: Deterministic event-driven waits replace fixed sleeps at every site the harness has hooks for; coverage delta from P1 shows no meaningful coverage went dark.
### Phase 3
- **MH-3a**: `session.exec()` at all 14 sites (marketplace/store.py ×12, identity/store.py ×2); zero `session.query(` remains in `ai_service/`; full suite green = equivalence proof.
- **MH-3b**: All 14 ResourceWarning sites closed at source — UploadFile (10, api/defense.py + api/marketplace.py), sockets (3, scripts/sandbox-agent.py), file handle (test_engine.py via Path.read_text); security-auditor review passed (PII/stream guarantees intact).
- **MH-3c**: `filterwarnings` = error + exactly the 2 module+message-scoped upstream ignores (TestClient httpx, anyio BlockingPortal); no blanket category ignores; nothing ignored for `ai_service.*`; full pytest run reports ZERO warnings.
- **MH-3d**: `fixture_loop_scope` pinned; the dead `if False` branch removed (tests/identity/test_identity.py:191-194).
### Phase 4
- **MH-4a**: ESLint flat config (typescript-eslint recommended + react-hooks, minimal viable set) in web, cli, and all three packages; `turbo lint` exercises all 6 workspaces; `pnpm lint` green.
- **MH-4b**: Zero eslint-disable orphans (all 5 resolved: removed or honored); zero new-config violations.
- **MH-4c**: RUF100 clean — all 81 inert noqa removed (0 remain), RUF100 in the ruff select set (inert suppressions structurally impossible going forward).
- **MH-4d**: Dead exports gone — ai-scenarios.ts + re-exports, NavItem/ThemeConfig, Spawn, MicControl (each removed-or-wired per usage, decision recorded); stale comments fixed (board-client, sandbox-terminal, pricing reviewed).
- **MH-4e**: Version single-source — pyproject = shipped line (v0.6.x), `FastAPI(version=)` from `importlib.metadata` (pinned by test), stale version comments gone; `engines` fields in root + apps package.jsons.
### Phase 5
- **MH-5a**: mypy at the relaxed-first baseline (check_untyped_defs, warn_unused_ignores, warn_redundant_casts, no_implicit_optional, warn_unreachable — NOT strict); `ai:typecheck` script + turbo task inside `pnpm typecheck`; green.
- **MH-5b**: All 27 `# type: ignore` meaningful — each removed (clean) or scoped `ignore[<code>]` with reason; `warn_unused_ignores` proves the inert set is empty.
- **MH-5c**: recharts@3 shipped — tsc green, static-export build green (chart pages SSR-rendered), web tests green; zero recharts v2 direct deps; live mount probe confirms the operational surface.
### Phase 6
- **MH-6a**: NFR purity gate passed — zero `feat:` commits on the milestone branch (D-001); any feature commit is a scope-creep P0.
- **MH-6b**: Multi-persona review of the full diff clean (P0s fixed in-phase); resource-cleanup security review confirmed.
- **MH-6c**: ci-audit reconstruction test + discipline checks green; ship executed (merge, milestone tag, Gitea release + binary + checksum assets, branches deleted, REQ-7-001..005 complete, checkpoint cleared).
## Milestone Must-Haves
- **MH-M1**: All five requirements complete with their REQUIREMENTS.md success criteria: REQ-7-001 (coverage/timeout/markers/turbo wiring + leak fixes), REQ-7-002 (evidence-based cuts + consolidation), REQ-7-003 (zero-warning runs), REQ-7-004 (all-workspace lint + meaningful suppressions + version single-source), REQ-7-005 (mypy baseline + recharts 3).
- **MH-M2**: pytest full run green with ZERO warnings; the fast subset (`ai:test:fast`) completes in seconds (<60s).
- **MH-M3**: Every suppression in the repo is enforced-and-meaningful or gone — noqa (81→enforced-only), eslint-disable (5 orphans→0), `# type: ignore` (27→scoped-or-removed).
- **MH-M4**: `pnpm lint` exercises all 6 workspaces; `pnpm typecheck` includes mypy; `pnpm test` runs the full three-workspace pipeline.
- **MH-M5**: Zero `feat:` commits (D-001 purity gate) — NFR refinement-only honored end to end.
- **MH-M6**: No regression value lost: every cut is per-assertion justified by the D-057 rule against coverage evidence; hermeticity preserved (no test touches `~/.nextcraft` or the cloud).
- REQ-11-001..005 all complete per their REQUIREMENTS.md rows.
- The E2E machine-checked proof (the milestone's reason to exist, tests/placements/test_e2e_audit.py): employer registers → claims → operator approves → posts → learner (verified 18+) applies → employer drives in_review → interview → offer → hire `{role_title, start_date}` → PlacementRow created → the global chain walks green GENESIS_HASH → head → the export reproduces identical chains + head_hash on re-export → the overview counts the learner ONCE toward 1,000 — every step pinned in tests/placements/.
- Zero new dependencies (D-112 — the chain is stdlib hashlib/json; export is pydantic + JSONResponse), zero workspace changes, zero mypy overrides, zero .env.example changes (no new knobs — recorded explicitly).
- The v0.6/v0.9/v0.10 wire contracts unchanged: marketplace/billing/employers suites green unmodified (the only marketplace seam changes are the get_application point read + grouped reads); the own-postings counts-only pin green unchanged (G-319); UpgradeRequiredError and every existing engine-client contract byte-stable.
- Wire pins green: placement.ts dual-schema pinned field-for-field against placements/base.py (D-110), status-union regex pinned.
- Suite counts: pytest fast lane + web + cli all green, zero warnings (the v0.7 regime holds).
- Docs: REQUIREMENTS/ROADMAP updated; deploy/README.md unaffected (no admin-box changes — recorded explicitly); the mock-first doctrine intact (no external systems, no third-party auditor integration — the export IS the deliverable; tests never call a cloud).
## Risks
- **Cutting a test that carried hidden regression value** — mitigated by D-057's falsifiable per-assertion rule + P1's coverage delta as evidence; kept pins are named after their regressions so the lesson survives.
- **filterwarnings=error surfacing latent flaky upstream warnings** — mitigated by W3-last ordering (proves the clean state over the full suite), the enumerated 2-ignore allowlist scoped by module+message, and no `ai_service.*` ignores (first-party fixed at source in W2).
- **pytest-timeout false-positiving real work** — 60s sits comfortably above the 20-30s polling loops; 180s for the real-server/userns tier; `@pytest.mark.timeout` for known outliers.
- **recharts 3 API break on chart surfaces** — mitigated by D-059's verification triad (tsc + SSR static build + existing web tests) and the fresh P1/P4 coverage/lint net; rollback = pin v2 while the fix lands.
- **mypy retrofit cost on a never-myped codebase** — mitigated by D-058's relaxed-first baseline (no full strict); cheap fixes only, scoped-code ignores otherwise; `warn_unused_ignores` delivers the milestone goal at realistic cost.
- **c8 coverage mis-reporting through the tsx loader** — D-052 probe picked c8 precisely because node's `--experimental-test-coverage` mis-reports through tsx; c8 handles the source maps; wired without a runner swap.
- **Helper consolidation breaking suite behavior** — helpers are moved verbatim (import paths only, D-060); the green suite is the equivalence proof; fixtures deliberately stay in conftest.
- **Version metadata lookup failing on un-installed runs** — D-061's safe fallback covers the non-installed path; pinned by test.
1. **Re-hire-after-revoke coherence gap (R-80)** — `hired → hired` is a 409 on every matrix-validated surface, so the re-hire is pinned as the placement-level operator route `POST /v1/placements/admin/placements/by-application/{application_id}/re-hire` + the `placement.rehired` ledger event, never an application transition — Task 2-2-02; the three-event sequence + the partial-unique IntegrityError arm tested.
2. **Hash-after-flush write shape (R-70)** — flush-then-hash-then-commit inside one transaction, ALL appends through the one transition_application, the no-delete-path pin — Task 1-1-03 (+ the 1-1-02 source pin); tamper/fork/concurrency tests green.
3. **created_at canonical serialization (R-79/R-69)** — ONE pinned normalization (`_as_utc(dt).isoformat()`) in `_canonical_event_json`; the tamper tests catch hashing/reading drift before the chain breaks on its first re-read — Task 1-1-02.
4. **_APPLY_TIMES limiter reset (R-77)** — tests/placements/conftest.py replicates the autouse reset from day one (the G-313 lesson applied before it ships, not after flaky archaeology) — Task 1-2-01.
5. **Methodology honesty block (R-67/R-68)** — the three pilot gaps (no duration check, no salary, attestation-not-third-party-verification) stated explicitly in the export methodology — Task 4-1-01; asserted present by Task 4-1-03.
6. **Export determinism rules (R-73)** — pinned list orderings (events by id; cohort month ascending; placements created_at then id), total generated_at exclusion, float absence — Task 4-1-01; the re-export test is the proof.
7. **Status-column ownership (R-78)** — the placements store is the ONLY post-creation writer of application.status; stated column-level in the module docstring + enforced by the D-100 discipline check at the P1 gate (Task 1-3-02).
8. **G-319 supersession pin split (R-77)** — counts-only stays on the own-postings list (test_postings_read.py:216 unchanged); learner_id appears on application-scoped routes only, cross-entity 403 carries no data — Task 2-1-02 pins both arms.
9. **Concurrent-transition arm (R-78)** — one-winner-one-IntegrityError on truly concurrent transition_application calls (the billing concurrent-consumption shape, tests/billing/test_store.py:335) — Task 1-1-03.
10. **CIRR specifics from knowledge, not live sources (R-67)** — the methodology text stands on its own definitions; CIRR is naming, not citation; the summary renders the prose an auditor reads without external references — Tasks 4-1-01/4-1-02.
## User-Facing Surface
1. **`pnpm test`** — the one command runs the full three-workspace pipeline (ai + web + cli) via turbo, all green.
2. **`pnpm ai:test:fast`** — the fast subset (no real servers, no user namespaces) completes in seconds (<60s): the inner dev loop.
3. **Coverage report** — produced by the standard test commands in all three workspaces (pytest-cov report; c8 reporters): what runs, visible.
4. **Warning-free output** — pytest runs print ZERO warnings; new first-party warnings fail the run (`filterwarnings=error`): hygiene is self-enforcing, not aspirational.
5. **`pnpm lint`** — a real lint gate across all 6 workspaces (ESLint for JS/TS, ruff for Python); `pnpm typecheck` includes mypy.
6. **Honest skips** — env-gated/skipped tests report as skips, never silently pass; the E2E remains one opt-in command (`CLI_E2E=1 pnpm cli:test:e2e`).
1. **/employer/applications (NEW)** — the hiring pipeline: own applications with status badges, per-target transition CTAs, the hire form `{role_title, start_date}` (a date input, future dates allowed); honest pending/empty/claim states.
2. **Employer dashboard — overview placements section** — real hired placements with role/start-date (the mock block REPLACED); talent-match/applicant-pipeline metrics stay mock-labeled.
3. **Job detail — application-status panel** — the learner's current status + timestamped history + placement confirmation when hired + withdraw CTA; honest 403/404/409 states.
4. **Admin moderation page — placements block** — aggregate counts toward the 1,000 guiding goal + per-employer lists + export links (summary in-tab, JSON download); 403 → honest locked state with token prompt.
5. **Talent search + candidate profile** — remain the v0.1 mock, honestly labeled, with NO placement badge (A-304 — no badge on mock pages).
6. **API surfaces** — `/v1/employers/applications/*` (session-gated transitions + own pipeline list), `/v1/marketplace/applications/{id}` + `/withdraw` (learner-gated, D-043 stack), `/v1/placements/admin/*` (operator-gated: transition, revoke, re-hire, overview, export, export/summary).
7. **Release notes** — the v0.10.x line ships placement tracking through the existing automated lane (no admin-box changes required).
## Happy Path (end-to-end, written BEFORE execute)
1. Fresh clone → `nextcraft bootstrap` (or `scripts/bootstrap.sh`) → venv + deps + env templates ready.
2. `pnpm test` → turbo runs ai-service (full pytest, zero warnings), web (tsx --test under c8), cli (default suite, E2E excluded) → all green, coverage reports printed.
3. `pnpm ai:test:fast` → the fast subset completes in seconds (<60s) — the tight feedback loop for daily work.
4. `pnpm lint` → all 6 workspaces lint clean; `pnpm typecheck` → 5 tsc projects + mypy green.
5. `pnpm build` → static export emits; chart pages SSR-render on recharts 3.
6. Opt-in: `CLI_E2E=1 pnpm cli:test:e2e` → the real 600s fresh-clone bootstrap runs under its 900s budget, green.
7. The milestone branch carries zero `feat:` commits — ship tags the milestone release with binary + checksum assets.
1. **Employer onboards (v0.10 rails unchanged):** register → claim their entity → operator approves → the account owns the entity.
2. **Employer posts:** create draft (package granted) → publish → the posting is live on the job board.
3. **Learner applies:** a verified 18+ learner applies from the job-detail page → application `submitted` → the status panel shows the timestamped entry.
4. **Employer drives the pipeline:** /employer/applications → in_review → interview → offer → the learner's panel reflects each step with its event history.
5. **Hire:** the employer submits `{role_title: "AI Orchestration Engineer", start_date: next month}` → hired (terminal) → the PlacementRow is created in the same transaction as the ledger events → the learner's panel shows the placement confirmation (role, employer, start date).
6. **Operator oversight:** the admin overview counts the learner ONCE toward 1,000; a mistaken hire → operator revoke → re-hire creates a fresh placement with a three-event ledger trail — the learner is never erased from the count by a correction.
7. **Auditor exports:** the admin placements block → the JSON export downloads (self-contained: cohort + evidence bundles + methodology + chain with head hash) + the summary opens in-tab → re-export reproduces the identical chain + head → the chain-walk instructions let a third party recompute every hash offline.
## UX Acceptance Criteria
1. `pnpm test` from a fresh clone: all three workspaces green, zero warnings in the output.
2. `pnpm ai:test:fast` completes in <60s (seconds-scale) and provably excludes `real_server`/`userns` tiers (marker counts printed).
3. Every standard test command emits a coverage report; the numbers are visible without extra flags or tools.
4. No silently-passing skips: any skipped test appears in the skip summary with a reason (env-gate, missing binary, documented constraint).
5. `pnpm lint` exercises all 6 workspaces and fails on violation; no inert noqa, no orphaned eslint-disable, no unscoped `# type: ignore` anywhere in the repo.
6. A hung test dies at its tier's timeout (60s/180s) with a clear failure — never an infinite CI hang.
7. The suite is smaller than the 785 baseline with every cut recorded per the D-057 rule and no meaningful coverage lost (coverage delta shown).
8. Test runs leave zero droppings: no `tests/sandboxes/` residue, no leaked env state, no `~/.nextcraft` writes.
9. Versions honest everywhere: `FastAPI(version=)` equals package metadata equals the shipped line; engines fields present and truthful.
1. **Honest states everywhere:** every new surface renders real loading/error/empty states — no spinners that lie, no silent failures, no fake data.
2. **Gate order never surfaces a 409 before a 403:** unauthenticated → 401, unclaimed → 403 claim prompt, foreign application → 403 with no data, only then matrix 409s — pinned on every transition route (G-311).
3. **401 → honest logged-out state:** SessionRequiredError renders the login CTA on employer surfaces — never a retry loop, never a silent redirect; admin surfaces render the token-prompt locked state on 403.
4. **Counts honesty:** the overview counts DISTINCT learners toward 1,000 (a learner placed twice counts once); revoked placements excluded from every count; counts render from the store's rows at query time — never a denormalized or cached counter.
5. **Export self-verification:** the summary carries the head hash + offline chain-walk instructions; the methodology states its own three pilot gaps AND the chain's proof scope + wholesale-rewrite limit + export-to-export continuity anchor (G-321) — employer attestation is never presented as third-party verification (A-304); **G-328: no "verified" placement wording on any live surface (source-pinned)**.
6. **Badge honesty:** a placement badge renders only on the learner's own application surface where the data is real; no badge until a placement exists; none ever on the mock talent pages.
7. **Mock labels retained where no backend exists:** talent-match/applicant-pipeline metrics stay mock-labeled; talent pages stay the v0.1 mock — never fake-convert, never leave a convertible surface on mock (D-092).
+133 -2
View File
@@ -16,7 +16,136 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
---
## Milestone v0.7 — Test Harness Review (ACTIVE, NFR refinement-only, started 2026-09-26)
## Milestone v0.11 — CIRR-Style Placement Tracking and Audit (ACTIVE, Feature, started 2026-10-01)
**Scope (D-098, auto-decided at SPECIFY under full autonomy D-007; founder scope confirmed 2026-10-01):** Activates **REQ-F-020 — CIRR-style placement tracking and audit** as REQ-11-001..005. The guiding outcome ("≥1,000 learners placed in AI-orchestration roles within 36 months, **verified by CIRR-style third-party audit**") is the school's single north star — v0.11 builds the machinery that makes it measurable and auditable. Every prerequisite is shipped: the learner identity spine (v0.5), the credential pipeline (v0.3), the marketplace applications flow (v0.6, `ApplicationRow`), the employer principals (v0.10, claimed entities + audit trail). v0.11 adds: the placement record model (learner + employer + role + start date, evidenced, immutable event log), the employer-side application state machine extending the v0.6 `submitted`-only status (hiring-stages transitions with audit), placement reporting surfaces (per-employer, per-learner, aggregate), and the **audit-grade export** — the evidence-backed dataset a third-party auditor can verify (cohort snapshots, per-placement evidence chains, tamper-evident event ledger, methodology documentation). Rationale: D-085's closing sentence names CIRR tracking as what the employer network "unlocks"; the guiding outcome is unmeasurable without it; no other deferred requirement (REQ-F-019 is medium and adds a surface, not the outcome). Confidence 0.80 ≥ threshold 0.60 (D-007).
**Out of scope:** human tutor marketplace (REQ-F-019, deferred), external third-party auditor integration (the export is the deliverable — no CIRR org exists to integrate with), learner-side employment verification (self-report stays out; employer-side state changes are the evidence), compensation/salary tracking (role + start date + employer suffice for placement counting; salary is out per CIRR-adjacent minimalism), public placement-stats marketing pages (internal + export only).
**Success (milestone-level):** every application in the marketplace has an auditable state history (submitted → in_review → interview → offer → hired/rejected/withdrawn, transitions employer-principal-driven and audit-logged); a placement record exists for every hired application with evidence (posting ref, application ref, timestamps, decision provenance); aggregate placement counts are computable from the ledger (per employer, per cohort month, cumulative toward ≥1,000); the audit export produces a self-contained, evidence-linked dataset (JSON + human-readable summary) whose placement claims are each traceable to immutable events; the mock-first doctrine holds (no external systems; tests never call a cloud). a placement record exists for every hired application with evidence (posting ref, application ref, timestamps, decision provenance); aggregate placement counts are computable from the ledger (per employer, per cohort month, cumulative toward ≥1,000); the audit export produces a self-contained, evidence-linked dataset (JSON + human-readable summary) whose placement claims are each traceable to immutable events; the mock-first doctrine holds (no external systems; tests never call a cloud).
## Clarify decisions (D-099+, CLARIFY stage full autonomy, 2026-10-01)
| ID | Decision | Rationale | Confidence |
|----|----------|-----------|------------|
| D-099 | Application status vocabulary = **snake_case set `submitted → in_review → interview → offer → hired / rejected / withdrawn`, forward-only with skip-forward, terminals have no outgoing edges**: the validated matrix (billing's `_VALID_TRANSITIONS` shape, billing/store.py:84-90) allows from any non-terminal status (a) any LATER stage (a fast-track `submitted → hired` is a legal hire — CIRR counts the hire, not the ceremony) and (b) any terminal (`rejected`/`withdrawn`); NO backward moves (no `offer → in_review`; the posting lifecycle draft→published→closed is strictly forward, store.py:289/609-632 — hiring inherits the discipline); `hired`/`rejected`/`withdrawn` are terminal — re-transition → 409 (`InvalidTransition`, the set_posting_status route contract); `withdrawn` is triggerable by the learner (own application, D-104) OR the operator (remediation, actor recorded — never by an employer session, who may `reject` instead); value naming is snake_case `in_review` (REQ-11-002's own text; billing's `past_due` precedent). Existing v0.6 rows are all `submitted` (store.py:211) — a valid matrix start, zero data migration. | The tree's two transition machines (posting store.py:609-632, subscription billing/store.py:84-90) both pin exact matrices with terminal empty-tuples; G-305's lesson (no phantom states, no un-matrixed moves) applies verbatim; skipping stages is honest pipeline reality (REQ-11-002 names the stages but no rule forbids acceleration, and an operator correcting a mis-entered pipeline must be able to jump forward). | 0.85 |
| D-100 | The state machine lives in **`ai_service/placements/` — the placements store owns ONE `transition_application(application_id, to_status, actor_type, actor_ref, role_title=None, start_date=None)` method that updates the marketplace `application` row + appends the ledger event + creates the `PlacementRow` on hired, ALL IN ONE TRANSACTION** (the decide_claim all-or-nothing precedent, employers/store.py:636-651: "the audit row rides the SAME transaction"); the transition matrix constant lives in `placements/base.py` (billing/base.py `CANONICAL_EVENT_NAMES` placement); the marketplace store KEEPS application insert/read ownership (insert_application unchanged; gains only a `get_application(application_id)` point read, the get_posting shape at store.py:578-584) — the placements store's cross-family write to the `application` table is the sanctioned deliberate exception: both stores share the one SQLite file, so one Session on the placements engine covers both tables atomically. The rejected alternative — route composes `marketplace.set_application_status` + `placements.append_event` as two commits — leaves a crash window where the status changed but no ledger row exists, which voids REQ-11-002's "every transition appends the placement ledger" (the exact tamper-evidence the milestone sells). | REQ-11-002's ledger-append requirement is only provable under one transaction; decide_claim is the in-tree proof that decision + audit event must land together; cross-module reuse is family-sanctioned (employers/base.py:22 subclasses marketplace.base.Posting — D-093); D-027 table ownership bends here because the feature boundary (application lifecycle) crosses the v0.6 table boundary, and the bend is one column-write executed by one named store method, never a route-side two-step. | 0.80 |
| D-101 | Hash-chain construction = **ONE GLOBAL chain over `PlacementEventRow` ordered by autoincrement id: each row stores `prev_hash` (the prior event's hash; `GENESIS_HASH = "0" * 64` module constant, test-pinned) + its own `hash` = `hashlib.sha256` (hex digest) over canonical JSON `{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at}` + `prev_hash`, serialized `json.dumps(..., sort_keys=True, separators=(",", ":"))`, created_at as ISO-8601 UTC** (deterministic; stdlib-only — the D-086 zero-dep doctrine, the D-077 HMAC-SHA256 crypto precedent); **`UNIQUE(prev_hash)`** makes a chain fork structurally impossible (two events claiming the same predecessor surface IntegrityError — the G-316 structural-backstop class); event rows carry BOTH a nullable indexed `application_id` (application-stage transitions log before any placement exists) and nullable `placement_id` (set from hired onward); **NO backfill**: pre-v0.11 applications have no `submitted` event — the export's evidence bundle carries the application record's own `created_at` (honest absence, never fabricated history, A-304; the G-302 doctrine cuts both ways). Actor vocabulary: `employer \| learner \| operator \| system` (the billing `_BILLING_EVENT_KINDS` shape, billing/store.py:92). | REQ-11-004 names "the event-ledger chain with head hash" — singular: one global chain with one head is the simplest verifiable shape (a single linear walk re-computes every hash; per-placement chains would need N heads and per-chain genesis bookkeeping for nothing at pilot scale); canonical sorted-key JSON is the standard deterministic-serialization answer and makes the re-export integrity test exact; SQLite's single-writer serialization plus the UNIQUE backstop closes the two-appenders-same-prev race the G-316 lesson demands. | 0.80 |
| D-102 | PlacementRow semantics = **immutable at creation; `status` is the ONLY mutable column with exactly one transition `active → revoked` (operator-only correction, the D-083 manual-remediation lane)**: `role_title`/`start_date`/`decided_at`/`learner_id`/`employer_id`/`posting_id`/`application_id` are set once at the hired transition and NEVER rewritten (a silent field rewrite is exactly the tamper the ledger exists to detect; the D-053 append-only enrichment doctrine); `decided_at` = the hired transition's created_at (store-stamped, `datetime.now(UTC)`, the insert_application precedent); the one-placement-per-application guard is a **partial UNIQUE index on `application_id` WHERE status='active'** (the G-316 `uq_pending_claim_per_entity` shape, employers/store.py:161-167) — a revoked placement stays in history, and a re-hire after a mistaken revoke creates a fresh placement for the same application, so an operator fat-finger never permanently erases a learner from the ≥1,000 count; `employer_id`/`learner_id`/`posting_id` are ID REFERENCES resolved from source rows at read/export (G-302 — never trusted denormalized copies). | REQ-11-001's plain "UniqueConstraint (application_id)" would make a mistaken hire uncorrectable forever (terminal application + unique placement = no re-hire path), which is operationally dishonest for an audit-grade dataset that must survive human error; the partial-index precedent exists in-tree for exactly this conditional-uniqueness shape; revoked placements are excluded from counts (D-107) so the correction is counting-honest too. | 0.75 |
| D-103 | `start_date` = **the employment start DATE (first day of work), a pydantic `date` — not the offer date, not a datetime**: the offer moment is already audit-truth as the hired transition's `decided_at` (D-102), so `start_date` answers "when did/will employment begin" (the CIRR question); timezone-free by construction (a calendar date has no tz — the cohort month D-107 keys on it); **future dates allowed** (hired today, starts next month — the normal case) and past dates allowed (retroactive recording of a hire that began last week); REQUIRED in the hire request body alongside `role_title` (employer-supplied at transition time, REQ-11-002's "hired creates the PlacementRow + role_title + start_date payload"); `role_title` is free text (defaults to nothing — never silently the posting title; the employer names the actual role). | A datetime would fabricate precision no employer supplies (nobody's start is "2026-11-03T14:22:51Z"); date-only matches every CIRR-adjacent reporting convention (monthly cohort tables); making both fields explicit-request-body requirements keeps the placement record employer-attested (the A-304 doctrine: the record carries what the principal actually asserted, never an inferred copy). | 0.85 |
| D-104 | Learner routes live in **`api/marketplace.py` (the existing apply surface) with the D-043 composition unchanged — G-5 allowlist → verified 18+ → `_check_apply_rate`**: `POST /v1/marketplace/applications/{application_id}/withdraw` (body `{learner_id}` — 403 when the application is not the caller's, 409 when terminal, 422-free) and `GET /v1/marketplace/applications/{application_id}?learner_id=` returning `{application, events[], placement|null}` (current status + full timestamped ledger history + the placement record when hired — REQ-11-005's outcome visibility + placement confirmation in one read); the withdraw rides the EXISTING apply-rate window (marketplace.py:66-76 — 5/rolling-60s per learner, no new throttle knob, D-088's auth-only throttle scope untouched); the routes compose the placements store via `app.state` DI (the territory rule, deps.py) for the history/placement sections and call the SAME `transition_application` store method (D-100) with `actor_type="learner"`. Learner-initiated edits go NO further: only `withdrawn` (REQ-11-005's "no learner-initiated edits… the withdrawn transition is the exception"). | D-097's binding left the learner gates untouched and pinned them to learner surfaces — the marketplace module IS the learner application surface (apply at marketplace.py:345-382 with this exact gate stack); the G-5 allowlist stays the pilot guard on every learner-gated route (A-306); riding the existing limiter avoids a new knob and its G-313 conftest-reset trap is already solved in tests/marketplace (conftest.py:123-131, mirrored into tests/placements per D-111). | 0.85 |
| D-105 | Employer transition routes = **per-target action routes under `/v1/employers/applications`** — `GET /v1/employers/applications?status=` (own pipeline), `POST .../{application_id}/in-review`, `.../interview`, `.../offer`, `.../reject`, `.../hire` (hire body `{role_title, start_date}`) — mirroring the posting lifecycle idiom (`/postings/{id}/publish`, `/postings/{id}/close`, employers.py:632-715); gate order (G-311): session 401 (`require_employer_session`, employers.py:173-191) → claim 403 (`_require_claimed`, employers.py:467-484) → cross-entity 403 (fetch application → posting → `posting.employer_id != claimed` → 403, the `_own_posting_or_404` shape at employers.py:487-503 applied through the application's posting) → transition 409 (never a 409 before a 403); every route rides the same `transition_application` method with `actor_type="employer", actor_ref="account:{id}"` (the D-091 checkout-metadata provenance shape) and appends an `employer_audit_events` row (the audit trail stays one queryable lane, D-089). **G-319 scoping (binding):** the v0.10 counts-only PII boundary was scoped to the own-postings LIST surface; v0.11 supersedes it for application-scoped reads/transitions — `learner_id` becomes visible ONLY for applications on the session's own claimed-entity postings (cross-entity → 403, no enumeration); the own-postings list's `applicant_count` stays counts-only. | A hiring pipeline without applicant identity is unworkable (REQ-11-002/003 inherently require it; A-408's "founder contacts employers out-of-band" was the no-employer-principal era's workaround, ended by v0.10); per-target routes keep the matrix OUT of request bodies (each route's store call names its target — one less validation surface, the publish/close precedent); G-319's intent was anti-enumeration/anti-cross-entity, and the 403-on-foreign-application arm preserves exactly that. | 0.80 |
| D-106 | Admin oversight = **`api/placements_admin.py`, prefix `/v1/placements/admin`, `require_admin` IMPORTED from marketplace_admin (the billing_admin.py:27 import precedent, employers_admin.py:47) — one generic remediation route `POST /v1/placements/admin/applications/{application_id}/transition` `{to, role_title?, start_date?}`** (validated against the same D-099 matrix; role_title/start_date required iff `to=hired`; `actor_type="operator"` with `OPERATOR = "operator"` route-level provenance, employers_admin.py:54 — no per-operator accounts exist, D-094), **plus `POST .../placements/{placement_id}/revoke`** (D-102's operator-only correction, one transition) and the reporting/export reads (D-107/D-108): `GET .../overview`, `GET .../export`, `GET .../export/summary`; no employer-session gate, no learner gate (a third principal class — D-097); token namespaces cannot collide (D-090); the operator can drive ANY matrix-legal transition including `withdrawn` on a learner's behalf (remediation with provenance — the D-083 manual-grant lane). | REQ-11-002 names "admin routes retain operator oversight (admin transition + audit, the D-094 lane)" — one generic route (vs the employer surface's per-target idiom) is the operator-tooling shape: fewer routes to audit, and the matrix validation lives once in the store (D-100); the export prefix matches REQ-11-004's own `/v1/placements/admin/export`; fail-closed on unset token is inherited free from require_admin (G-11 boot warning already covers it, main.py:251-256). | 0.85 |
| D-107 | Reporting semantics = **counts computed from rows at query time (never denormalized counters — G-302), with the guiding-outcome metric = DISTINCT LEARNERS with ≥1 `active` placement, all-time cumulative toward 1,000**: a learner placed twice counts ONCE ("≥1,000 learners placed" — the PROJECT.md guiding outcome is learners, not placements); `revoked` placements are excluded from every count (D-102's correction semantics); **cohort month = the calendar month of `start_date` (`YYYY-MM`)** — not application month, not hired month (CIRR cohorts on employment start); `GET /v1/placements/admin/overview` returns `{total_active_placements, distinct_learners_placed, guiding_goal: 1000, by_employer: [{employer_id, count}], by_cohort_month: [{month, count}]}`; per-employer view = the claimed entity's `hired` applications with role/start-date (REQ-11-003's employer-dashboard placements section); joins run through the existing stores (employer identity via `employer_accounts.employer_id`/EmployerRow, posting via postings, learner via application rows — REQ-11-003's own "counts join through existing stores"); batched reads use the `count_applications_by_posting` grouped-read discipline (store.py:915-932) — never per-row N+1. | The guiding outcome's noun is "learners" (PROJECT.md line 7) — counting placements would inflate the north star; date-only cohort keys make month buckets deterministic (D-103); counting from rows keeps revoke honest without counter-maintenance writes (the G-306 lazy-evaluation lesson: derived state computed at read, never a timer or a stored tally). | 0.85 |
| D-108 | Export format = **`GET /v1/placements/admin/export` returns one self-contained JSON document**: `{schema_version, generated_at, summary: {distinct_learners_placed, total_active_placements, revoked_count, by_cohort_month}, cohort: [placement records with employer/posting/application refs + role_title + start_date], evidence_bundles: [per-placement {posting_record, application_record, transition_history[], decision_provenance} — each resolved from SOURCE ROWS at export time (G-302)], methodology: {placement_definition, counting_rules, verification, scope}, ledger: {events: [{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at, prev_hash, hash}], head_hash}}`; **`GET /v1/placements/admin/export/summary`** returns the human-readable rendering (text/markdown: totals table, per-cohort table, methodology prose, head hash, chain-walk verification instructions); scope is honest pilot-bounded (every placement in the DB, unbounded by date — REQ-11-004's own scoping); **re-export determinism: the integrity test asserts identical `ledger.events` chains + identical `head_hash` across two exports, with `generated_at` excluded from comparison** (the chain is a function of stored rows; generated_at is the export's own timestamp, not ledger content); the summary is generated from the same JSON (one truth source — the summary can never drift from the export). | REQ-11-004 names all four dataset components ((a) cohort snapshot, (b) evidence bundles, (c) methodology, (d) chain+head) — this is exactly that shape with names an auditor can consume; "resolved from source rows, never denormalized copies" means snapshot-at-export (the export EMBEDS resolved source content — that's what self-contained means) while the live rows stay the only write path; methodology-as-structured-JSON keeps it versioned and machine-checkable while the summary route renders the prose an auditor reads. | 0.80 |
| D-109 | Web conversion scope = **four surfaces, no new client module**: (1) new `/employer/applications` page (the hiring pipeline: own applications with status badges + per-target transition CTAs — honest pending/empty/403-claim states, the A-406 board-client discipline); (2) the employer overview page gains a real placements section (hired applications with role/start-date, honest empty state) — its existing applicant-pipeline/talent-match mock metrics stay mock-labeled (D-092: never fake-convert, never leave a convertible surface on mock); (3) the job-detail client (the existing apply surface, job-detail-client.tsx) gains an application-status panel: current status + timestamped history + placement confirmation when hired, via D-104's learner read (401/403/409 discrimination through existing `EngineError.status` checks — job-detail-client.tsx:103-105 precedent, NO new error classes); (4) the admin moderation page's EmployerNetworkPanel area gains a placements block (aggregate counts + per-employer lists via D-107 overview, adminToken() transport per G-308) + an export link (summary opens in-tab; JSON export downloads). **The talent profile's placement badge stays absent** — talent profile pages remain v0.1 mock (D-092 binding), and a badge on a mock page is A-304 dishonesty; a REAL badge renders only on the learner's own application surface (D-104's placement section) where the data is real. | REQ-11-003 names the employer dashboard + moderation-page surfaces; REQ-11-005 names the learner application surface (the job detail page IS that surface today — apply + confirmation live there, job-detail-client.tsx:50/253); D-092's conversion doctrine (real where a real backend exists, mock where none does) maps cleanly: placements have a backend, talent profiles do not. | 0.80 |
| D-110 | Wire discipline = **`packages/types/placement.ts` (ApplicationStatus union, Placement, PlacementEvent, EmployerApplicationSummary, transition request/response shapes, PlacementOverview, PlacementExport) dual-schema pinned field-for-field against `ai_service/placements/base.py` pydantic contracts in `apps/web/tests/pins.test.ts`** (the D-093 template — the employer.ts pin block); engine-client.ts gains a **placements section** (no new client module — D-092's binding: one client module keeps parseError discrimination in one place): employer list/transition/hire functions, learner application-status/withdraw, admin overview/export — every fetch AbortController-equipped (A-406), 401 → SessionRequiredError reuse (engine-client.ts:126-131), no new error classes. | Every prior cross-boundary wire (PostingSummary D-084, employer.ts D-093) rides exactly this rail from day one; the placement surface is a new cross-boundary contract and the pins make drift structurally detectable (G-108 discipline); the 403 cross-entity arm needs no client-side class — components render the honest "not your application" state from EngineError.status. | 0.85 |
| D-111 | Testing doctrine = **`tests/placements/` mirroring D-096: real flows against create_app + TestClient, tmp_path DBs, zero network by construction, no MockTransport (no external calls exist), mypy green with no new overrides (stdlib only — D-086)**: state-machine 409 matrix arms (every illegal move incl. backward + terminal-source), gate order pins (employer transition: no-token → 401, unclaimed → 403, foreign application → 403 — never a 409 first; learner withdraw: unallowlisted → 403, unverified → 403, foreign application → 403, terminal → 409), hired E2E (transition → PlacementRow created → ledger events chained → employer dashboard placement visible), revoke + re-hire (partial-unique arm: second active placement on the same application → IntegrityError surfaced), **hash-chain tamper tests** (direct row mutation via a second engine → verification walk fails; fork attempt → UNIQUE(prev_hash) surfaces), **export determinism** (two exports → identical chains + head, generated_at excluded), learner visibility (history + placement confirmation), reporting counts (distinct-learner rule: one learner, two placements → counts once). `tests/placements/conftest.py` carries the G-313-shape autouse reset for the marketplace apply-rate limiter module state (the withdraw route rides it — tests/marketplace/conftest.py:123-131 pattern) — the suite cannot self-429. | The billing/employers suites are the proven template (behavior over the real app, real scrypt, honest assertions); the tamper and determinism tests are THIS milestone's reason to exist — an audit ledger nobody has tried to break is theater; the limiter reset is the G-13 lesson applied before it ships (v0.10 caught it only after flaky archaeology). | 0.85 |
| D-112 | Migration/compat = **9th D-027 store, tables `placement` + `placement_event` on the shared DB file, `SQLModel.metadata.create_all` idempotent — zero migration** (the D-095 shape verbatim): `SQLitePlacementStore` mirrors employers/store.py mechanics exactly (WAL/synchronous=NORMAL/busy_timeout/foreign_keys pragmas at connect, `sa_type` portable columns, `_validate`/`_normalize` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz-aware↔naive normalization, `_constraint_error` helper); bare `placement` is FREE (D-095's prefix rule was collision-driven — marketplace owns `posting`/`employer`/`application`, no `placement` table exists); the marketplace `application` table gains NO new columns (status is already a plain str column, store.py:211 — the D-099 vocabulary extends the validated set, existing `submitted` rows stay valid); lifespan wiring = the getattr-guard template (main.py:195-200) + `close()` in the finally block (main.py:336); routers mount in main.py beside the employers block (main.py:400-411). No new dependencies (hash chain is stdlib `hashlib`/`json`; export is pydantic + `JSONResponse`). | Seven prior stores never broke an existing DB via this exact pattern (D-095's 0.90 confidence carries forward); create_all checkfirst adds the two tables to every existing deployment at next boot; the zero-dep claim stays intact (D-086/R-66) — sha256 + canonical JSON need nothing the venv lacks. | 0.90 |
## Milestone v0.10 — B2B Employer Network (COMPLETE, Feature, 2026-09-30, shipped as v0.9.6)
**Scope (D-085, auto-decided at SPECIFY under full autonomy D-007; founder scope confirmed 2026-09-30):** Real employer auth/accounts and employer self-serve — the milestone every prior decision sequences next: A-407 ("Real employer auth lands post-payments with the B2B employer network" — payments shipped v0.9), A-401 (employer self-serve posting management deferred pending employer auth), D-076/D-079 (operator-mediated checkout + contact-path CTAs were honest *because* no employer principals exist). Employers become principals: registered accounts with sessions, claiming their existing employer entity (the v0.6 `EmployerRow` ingested data), self-serve posting management (draft → published → closed on their own entities), self-serve checkout (the v0.9 billing catalog with employer-principal buyers instead of operator mediation), and the employer dashboard surface converting from the v0.1 mockup to real data. Rationale: the guiding outcome (≥1,000 placements) needs a self-serve employer side; the supply side (v0.6), monetization rails (v0.9), and install-anywhere distribution (v0.8) are all in place — the employer network is the missing B2B surface that unlocks CIRR tracking (REQ-F-020 needs employer-side placement data) and closes the last mock-only surface. Confidence 0.80 ≥ threshold 0.60 (D-007).
**Out of scope:** CIRR placement tracking (REQ-F-020, deferred), human tutor marketplace (REQ-F-019, deferred), multi-tenant SSO/OIDC enterprise login (email+password at pilot), employer team/multi-seat management, posting analytics beyond the existing enriched views.
**Success (milestone-level):** an employer can register an account, claim/verify their employer entity, log in, manage their own postings through the full lifecycle, purchase posting packages/talent-access plans through self-serve checkout with entitlement enforcement applying to their own posts, and see real data in the employer dashboard; admin routes retain operator oversight (impersonation-free, audit-logged); all auth is session-based with hashed credentials, rate-limited, and fail-closed on unconfigured secrets; the mock-first doctrine holds (no external auth provider, tests never call a cloud).
## Clarify decisions (D-086+, CLARIFY stage full autonomy, 2026-09-30)
| ID | Decision | Rationale | Confidence |
|----|----------|-----------|------------|
| D-086 | Password KDF = **stdlib `hashlib.scrypt`, zero new dependencies**: per-password 16-byte random salt (`secrets.token_bytes`), OWASP-floor params N=2^14/r=8/p=1, dklen=32, stored self-describing as `scrypt$16384$8$1$<salt_b64>$<hash_b64>` so params can evolve without invalidating stored rows; verification compares derived bytes via `hmac.compare_digest`; params are module constants pinned by test (the D-083 PAST_DUE_GRACE_DAYS shape). | passlib is unmaintained and ships no type stubs — adding it would force the FIRST `ignore_missing_imports` mypy override, breaking the v0.7 pyproject regime ("currently NONE" — pyproject.toml:143-151); bcrypt adds a compiled dep to the deb/docker/arm64 matrix (D-069) for nothing; scrypt IS a library-grade memory-hard KDF (OpenSSL-backed, probe-verified in the shipped venv) and the tree's crypto precedent is stdlib-only end-to-end (HMAC-SHA256 webhooks D-077, ingest tokens v0.4.7, `secrets` minting). pypdf was "the ONLY new v0.6 dependency" — the zero-dep discipline holds. | 0.80 |
| D-087 | Employer sessions = **server-side rows in the new `employer_sessions` table (8th D-027 family), opaque token minted per login, stored hashed**: token = `secrets.token_urlsafe(32)` (CSPRNG — per-boot entropy inherent, the factory.py mint precedent); the row stores only `sha256(token)` and lookups re-hash the presented token, so a DB-file leak never yields usable credentials (the G-108 secrets doctrine extended to storage). TTL = fixed 7 days from login (module constant, test-pinned) evaluated LAZILY at read (G-306 — no timer); logout DELETEs the row + audit event (revoke = lookup miss → 401); multiple concurrent sessions per account (per-device rows) are allowed. The agents/ `InMemorySessionStore` is NOT reused — it is LRU-evicted chat context (D-019), a different concern; principal state is a durable D-027 row like everything else persisted. | REQ-10-001 already fixes "server-side store" (signed tokens/JWTs are off-spec); opaque random tokens need no signing key (server-side lookup is the truth source) and no per-boot secret to manage; hashed-at-rest is the standard session-store discipline at zero cost. | 0.85 |
| D-088 | Auth throttling = **in-memory sliding windows in api/ (process-local, the G-5 precedent)**: (a) per-EMAIL failed-login window — default 5 failures / rolling 15 min → 429 (settings knob, the `identity_submits_per_min` shape); (b) a global per-min cap on login+register attempts (the `_check_global_create_rate` deque, api/sandboxes.py:123) → 429, bounding cross-email credential-stuffing volume; successful login clears the email's failure window; 429s carry the honest "wait" detail. Keyed by the SUBMITTED EMAIL, not client IP — behind HAProxy every request's `request.client.host` is the proxy (the tree has zero IP-trust machinery; D-039 single-port). | The identity `_SubmitRateLimiter` (api/identity.py:97-116) and sandbox create-cap are the proven in-tree throttle patterns — same module-state acceptability (D-019: one process, one box); IP-based keys would be fake security on this topology; brute-force economics at pilot are bounded by the two windows without any new infra. | 0.80 |
| D-089 | Claim/verify = **claim → pending → admin decision; evidence recorded, never auto-activating at pilot**: `POST /v1/employers/claims {employer_id}` (authenticated) creates an `employer_claims` row status=pending with computed evidence `{account_email_domain, entity_website_domain, match}` (normalized registrable-domain comparison — stored as provenance); `employer_accounts.employer_id` is UNIQUE and set on approval (the REQ's "one account : one entity" UniqueConstraint); one PENDING claim per entity + one APPROVED owner per entity enforced check-first in the store (the `insert_application` precedent); rejection frees the entity for a new claim. Admin approve/reject routes (require_admin) set decided_by/decided_at + append `employer_audit_events` (the billing_events provenance shape). Auto-approval on domain match is DEFERRED: without email verification the match is forgeable, so the admin queue DISPLAYS the signal but the operator decision activates. | The seeded corpus websites are example.com placeholders — no real verification surface exists at pilot; REQ-10-002 explicitly retains "admin routes for claim review/approve/reject (operator oversight)"; an auto-approve on forgeable evidence would be a dishonest gate (the A-304 doctrine — never present unverified as verified). | 0.80 |
| D-090 | Session transport = **`Authorization: Bearer <session-token>`** with a `require_employer_session` dependency (api/employers.py — the require_admin extract→verify→return shape): 401 on missing/expired/unknown token (the webhook-HMAC credential precedent), 403 stays the authorization failure (cross-entity access); the dep returns the session + account context (claimed employer_id included). NOT a cookie: D-039 static export has no Next middleware — the client attaches the header explicitly on every fetch (A-002); an explicit header is CSRF-immune by construction (no ambient credential, no SameSite machinery). CORS `allow_headers` gains `"Authorization"` (main.py:341 — the E87-class lesson: the v0.3 PUT preflight omission broke every cross-origin Save; tests/api/test_cors.py extended to pin it) for the two-port dev + D-038 network-mode topologies. Token namespaces cannot collide: admin routes compare against the env token, employer routes hash-lookup sessions — a token of one class fails the other's gate. Operator routes live in a separate `api/employers_admin.py` mounting `require_admin` imported from marketplace_admin (the billing_admin.py:27 import precedent). | Parallel to require_admin's dual-header acceptance but single-header for one principal class; same-origin production fetches skip CORS entirely; the dev topology needs the allow-list entry or every employer preflight dies — caught before it ships this time. | 0.85 |
| D-091 | Entitlement reuse = **one shared api-layer helper, two principal gates composing it**: extract the 402 gate from marketplace_admin.create_posting (lines 181-220) into a shared api/ function `consume_posting_entitlement(store, employer_id, upgrade_cta)` raising the canonical 402 dict payload `{reason, posts_used, posts_limit, upgrade_cta}`; the admin route passes the admin-checkout CTA, the employer route the employer-checkout CTA — reason/posts_* identical, `UpgradeRequiredError` contract unchanged (D-080/D-084 wire stability). `consume_posting` itself (G-304 single conditional UPDATE) is untouched — no store change, no billing/→api/ boundary violation (the helper lives in api/ beside both callers, D-027/D-048 territory preserved). Employer checkout `POST /v1/employers/billing/checkout {item_id}` mirrors billing_admin.create_checkout with the buyer = the session's claimed entity (403 + claim-CTA when unclaimed; metadata operator_ref → account provenance) and an employer-scoped mock-complete arm mounted only under provider=mock, completing one's OWN session (D-082 transposed to the employer principal; success/cancel URLs from public_base_url per G-310). | REQ-10-004 demands the exact v0.9 check parameterized, not reimplemented — extraction is the only no-drift shape; the billing webhook/provider/store path is reused verbatim (REQ-10-005 "the v0.9 billing module reused"), only the principal derivation changes. | 0.85 |
| D-092 | Web conversion = **employer section in engine-client.ts (no new client module) + sessionStorage `nc-employer-session`**: engine-client.ts is THE fetch module (identity/billing/marketplace sections already live there; pins.test.ts pins its content) — the employer section adds register/login/logout/me, claim, own-postings CRUD/publish/close, own-checkout, own-entitlement + a `SessionRequiredError` (401 → the G-308 honest logged-out state, never a retry loop; logout clears storage + calls the revoke route; every fetch aborts via AbortController — the A-406 board-client discipline). Storage = sessionStorage (the G-308 admin-token wrapper is the only in-tree credential-storage pattern; tab-scoped bounds XSS token exposure vs localStorage's persistent copy; no pilot requirement demands cross-tab persistence). Conversion scope: postings page (real own-postings lifecycle), overview page (real active-postings count + per-posting applicant counts from the applications table; talent-match/placement metrics stay mock-labeled — no backend computes them), employer profile (claimed entity via the existing public GET /v1/marketplace/employers/{id}), new /employer/login + /employer/register pages; **talent search + candidate profile pages stay the v0.1 mock** (no candidate backend exists in any milestone — v0.6 built postings/search over POSTINGS; a talent-access search backend is honestly out of scope, their mock imports remain). | One client module keeps parseError/error-class discipline in one place (a second module would fork the 402/401 discrimination); mock stays only where no real data source exists — the honest-surface rule cuts both ways (never fake-convert, never leave a convertible surface on mock). | 0.78 |
| D-093 | Wire types = **`packages/types/employer.ts`** (EmployerAccount, EmployerClaim, EmployerPublicProfile reuse where shapes coincide — PostingSummary is reused from marketplace.ts, never duplicated, and the entitlement view reuses the v0.9 shapes) dual-schema pinned field-for-field against `ai_service/employers/base.py`'s pydantic contracts in `apps/web/tests/pins.test.ts` (the G-108 discipline; the billing.ts pin block, pins.test.ts:202-230, is the template — every field asserted both sides). | Every prior cross-boundary wire (PostingSummary, SearchResult, Package/Plan) is pinned this way (D-084); the employer surface is a new cross-boundary contract and gets the same rail from day one. | 0.85 |
| D-094 | Admin network overview = **the admin marketplace moderation page gains the network sections** (app/(admin)/admin/moderation/page.tsx — where the v0.9 EmployerEntitlementPanel already landed, REQ-9-004's operator lane): employer accounts list (email, claimed entity, entitlement state columns), claim queue (evidence + domain-match signal, approve/reject actions), fed by new operator routes in api/employers_admin.py (require_admin + audit events): GET /v1/employers/admin/accounts, GET /v1/employers/admin/claims, POST .../claims/{id}/approve, POST .../claims/{id}/reject, GET .../audit — web transport via the existing `adminToken()` G-308 sessionStorage wrapper + honest 403 locked states. | REQ-10-005 names "the operator oversight lane"; the moderation page is the established admin marketplace surface (employer claims are the marketplace-moderation concern — REQ-025's verification queue was its v0.1 mock ancestor); one admin surface keeps the operator's one-glance workflow instead of fragmenting across pages. | 0.80 |
| D-095 | Migration/compat = **8th D-027 store on the shared DB file, `SQLModel.metadata.create_all` idempotent — zero migration**: `SQLiteEmployerStore` mirrors billing/store.py mechanics exactly (WAL / synchronous=NORMAL / busy_timeout / foreign_keys pragmas at connect, sa_type portable columns, `_validate`/`_normalize` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz-aware↔naive normalization); `create_all` checkfirst semantics add the 4 tables (`employer_accounts`, `employer_sessions`, `employer_claims`, `employer_audit_events`) to existing DBs at next boot — the billing store shipped the same way against the marketplace-era file (proven compat). Lifespan wiring: the state-injection override (main.py `if getattr(app.state, "employer_store", None) is None: construct from settings.db_path`) + `close()` in the finally block (the billing_store wiring, main.py:182-190/325). The `employer_` prefix is mandatory — the bare `employer` table name is taken by marketplace's EmployerRow (store.py:145). | The 7 prior stores never broke an existing DB via this exact pattern (D-027 family replication is the most-repeated store contract in the tree); REQ-10-001's table names already carry the prefix. | 0.90 |
| D-096 | Testing doctrine = **real flows against create_app + TestClient, tmp_path DBs, real KDF, zero network by construction**: `tests/employers/` (the billing suite layout): register/login/logout/me, throttle 429 windows (both knobs), claim → admin approve → own-posting lifecycle → cross-entity 403s, entitlement 402 arms via direct store grants (the test_entitlement_enforcement.py:60 precedent), employer checkout + employer mock-complete E2E through the REAL webhook path (the D-082 proof shape). Password hashing runs the REAL scrypt in tests (N=2^14 ≈ tens of ms per hash — honest, never a fake-hash path; tests never assert hash bytes, only accept/reject behavior; salts random per hash — determinism is at the behavior level, the mock-provider verdict lesson). No MockTransport needed — auth has no external calls; the conftest zero-network guard stays satisfied; mypy green with no new overrides (stdlib only). | The suite's contract is behavior over the real app (billing/billing_admin/entitlement tests are the template); a reduced-KDF test profile would fork the code under test — the exact drift the honest-surface rule forbids. | 0.85 |
| D-097 | Gates = **no learner gates on employer routes; email verification + self-serve password reset deferred (honest out-of-scope)**: employer routes compose ONLY the employer session gate — no `require_verified_age`, no G-5 learner allowlist (employers are a third principal class, not learners; D-043's composition on the learner routes — apply/resume/sandbox — is untouched and keeps allowlist → verified 18+ → rate caps). Registration stores email UNVERIFIED (displayed as such; that is why claim evidence is advisory per D-089); no email/notification infra exists (the A-408 out-of-band precedent) so email verification and self-serve password reset are deferred and documented honestly in the surface copy; admin retains remediation authority via the operator routes + `employer_audit_events` trail. | Employer accounts are B2B adults buying posts, not gated minors — the 18+/16+ gates exist to protect learners on learner surfaces (D-043); inventing an email-verification path without any mail transport would be security theater (A-304: never present unverified as verified). | 0.85 |
---
## Milestone v0.9 — Payment Engine (COMPLETE, Feature, 2026-09-29 → 2026-09-30, shipped as v0.8.6)
**Scope (D-074, auto-decided at SPECIFY under full autonomy D-007; founder scope question left open post-v0.8):** Activates **REQ-F-018 — payment processing and subscription management** as REQ-9-001..005. The pricing page (REQ-017, critical since v0.1) gains its real backend: posting packages (single, bundle, enterprise), talent-access plans for employers, and subscription tiers — with checkout, provider webhooks, entitlement enforcement on posting creation, and a subscription lifecycle (renew/cancel/upgrade/downgrade) under a provider-agnostic protocol (D-014 mock-first pattern). Rationale: the guiding outcome (≥1,000 placements via the Nextcraft marketplace) requires a monetized, self-serve employer side; the marketplace engine (v0.6) built the supply side, ship-anywhere (v0.8) made the stack installable anywhere — payments is the only high-priority requirement deferred since v0.3 that directly unblocks employer self-serve (A-407: "real employer auth lands post-payments with the B2B employer network") and CIRR-style placement tracking needs paid postings to track. Confidence 0.80 ≥ threshold 0.60 (D-007).
**Out of scope (landed in v0.10):** real employer auth/accounts (B2B employer network), multi-currency, tax/VAT handling beyond passthrough, invoice PDF generation, Stripe-specific UI components, CIRR tracking (REQ-F-020, deferred).
**Success (milestone-level):** an employer (via admin-mediated flow at pilot) can purchase a posting package or talent-access subscription through a real Stripe-compatible checkout; webhooks update entitlements automatically; posting creation enforces package limits honestly; subscription lifecycle transitions (renew/cancel/upgrade) work end-to-end; the pricing page reflects real catalog data; all provider integrations are mock-first (tests never call Stripe). — ACHIEVED, all 5 REQ complete, shipped as v0.8.6 (release with full asset matrix + registry image per D-064).
## Clarify decisions (D-075+, CLARIFY stage full autonomy, 2026-09-29)
| ID | Decision | Rationale | Confidence |
|----|----------|-----------|------------|
| D-075 | Both providers ship in-milestone: `mock` (deterministic, default) and `stripe` (real Stripe-compatible REST via the shared httpx pool — checkout session create/retrieve, subscription cancel/upgrade, event signature verify). Tests exercise the stripe provider ONLY through `httpx.MockTransport` (the openai-audio/embedding precedent — byte-contract pinned, cloud never called); `AI_PAYMENT_PROVIDER=stripe` is env-gated and needs `AI_PAYMENT_API_KEY` — unset key falls back to mock with a loud G-11 boot log (voice/embedding factory pattern, `billing/factory.py` + main.py lifespan catch). | The D-030 openai-audio precedent: a real provider shipped alongside mock with MockTransport byte-contract tests. A protocol without a real implementation is unproven; a real implementation without mock is untestable. Pilot runs mock; the real path is config-away. | 0.80 |
| D-076 | Buyer model at pilot = **operator-mediated** (extends A-401): checkout sessions are created via admin-token-gated routes (`/v1/billing/admin/checkout`) on behalf of an employer ref in the request body; no employer principals exist (A-407). Every purchase records `operator_ref` (admin token identity = "operator") + `employer_id`. Webhook route is public (server-to-server) but HMAC-verified (D-077). | Employers are data, not principals (A-407); the admin token dep (`require_admin` returning Settings) is the only operator identity mechanism in the codebase. Self-serve checkout requires employer auth — explicitly post-payments scope. | 0.85 |
| D-077 | Webhook signature = **HMAC-SHA256 over `"{timestamp}.{raw_body}"`** (Stripe's own scheme shape), verified with `hmac.compare_digest`; secret from `AI_WEBHOOK_SECRET` env, **unset → per-boot random** (the `telemetry_ingest_secret` pattern) — meaning webhooks fail closed when unconfigured, which is correct for a payment surface. Missing/bad signature → 401, event_id dedupe via UniqueConstraint + check-first (the `insert_application` at-least-once pattern). Raw body read via `await request.body()` (the resume route is the nearest raw-request precedent). | The v0.4.7 ingest-token pattern (per-boot secret, compare_digest, length-prefixed binding) is the proven in-tree HMAC precedent; Stripe's timestamp.body scheme is the industry shape and tolerates clock skew via a replay window (±5 min, pinned by test). | 0.80 |
| D-078 | Catalog = **seeded DB tables, not a code constant**: `packages` (one-time: single $49/1 post/30d, bundle $399/10 posts/90d each, enterprise) + `plans` (recurring: starter $99/mo, pro $299/mo, enterprise-talent custom) seeded idempotently at store init from a versioned code constant (the SeededCorpusSource `seed_corpus.json` pattern). Prices = integer cents, currency `usd` fixed. Purchases **snapshot** price/name at buy time (catalog edits never rewrite history). | The pricing page and admin checkout must read ONE source (data-driven tier copy, REQ-9-002); DB tables allow future admin price edits without code changes while snapshots keep financial history immutable. Code-constant-only would make REQ-9-002's "no hardcoded tier copy drifting from backend" harder to prove. | 0.80 |
| D-079 | Pricing page = **server shell retained** (static-export metadata stays — A-411) + new client component fetching `/v1/billing/catalog` (the A-406 board-client pattern: AbortController, honest loading/error/empty states). CTA buttons at pilot = **contact path** (mailto + "operator-mediated checkout" copy) — NOT self-serve purchase (no employer auth, D-076). The real checkout trigger lives in the admin surface (operator buys on behalf of an employer). | Self-serve CTAs without employer auth would be a dishonest button; the admin-mediated purchase is the honest pilot flow. The pricing page's job in v0.9 is truth: real catalog data, real states. | 0.80 |
| D-080 | Entitlement model: `purchases` table (one-time packages: `posts_limit`/`posts_used`/`expires_at` snapshot) + `subscriptions` (recurring plans). Posting creation consumes 1 post from the **newest non-expired purchase with remaining posts**, else 402 with upgrade-CTA dict payload (the `require_verified_age` dict-detail precedent); enterprise = manual unlimited grant (admin route, provenance-recorded). Store tables (7th D-027 family): `packages`, `plans`, `purchases`, `subscriptions`, `subscription_events`, `checkout_sessions`, `billing_events` — `purchases` + `billing_events` added to the REQ-9-001 list (one-time packages + full audit trail need their own tables; a one-time purchase is NOT a degenerate subscription). | Mixing one-time packs into subscriptions overloads the state machine (REQ-9-005's states don't apply to a 10-post bundle); the identity/store.py + marketplace/store.py replication pattern makes an 7-table store cheap. | 0.75 |
| D-081 | Money = **integer cents** end-to-end (pydantic `int` with ge=0, TS `number` documented as cents); no floats anywhere in billing; currency `usd` only (multi-currency out of scope). | Float money is the classic billing bug; Stripe itself is cents-native. The dual-schema pins (pins.test.ts) enforce the int wire type both sides. | 0.90 |
| D-082 | Mock checkout completion = **a mock-provider-only confirm route** (`POST /v1/billing/mock/complete`, mounted ONLY when provider=mock): it mints a properly-signed webhook event and POSTs it through the REAL webhook processing path (fetch to self or direct handler call — same code path, proven by test). This is the E2E payment proof without cloud. The route 404s when provider=stripe. | The pipeline must be provable end-to-end in tests (webhook → dedupe → entitlement); a confirm route mounted only in mock mode is the same shape as the sitemap's env-gated routes and can never be hit on a real deployment (factory mounts it conditionally). | 0.70 |
| D-083 | Subscription lifecycle: states `trialing → active → past_due → cancelled|expired` (+ `upgraded`), transitions driven ONLY by webhook events or admin manual actions. **Event names are OUR canonical names, normalized at the provider boundary** (R-43: Stripe has no `subscription.renewed`/`checkout.completed`): `checkout.completed` ← `checkout.session.completed`; `invoice.paid` ← `invoice.paid` (also accept `invoice.payment_succeeded`); `subscription.cancelled` ← `customer.subscription.deleted`; `subscription.renewed` ← `invoice.paid` on a renewal invoice (corroborated by `customer.subscription.updated`); provider event ids stored + deduped verbatim (never normalized). Completion predicate `status=complete AND payment_status=paid` (R-37). `past_due` grace = 7 days (module constant, test-pinned) before expiry downgrade; renewals extend `expires_at` idempotently (event_id dedupe makes double-fire safe); manual grant/revoke records `source: "manual"` + operator provenance in `billing_events`. Invalid transitions → `InvalidTransition` → 409 (store precedent). | At-least-once webhooks + idempotent state transitions is the A-309 seq-lease lesson applied to billing; manual remediation must be audit-visible (REQ-9-005's "recorded as manual events with provenance"). Research R-41..R-45 verified the state/event mapping against Stripe's actual enums. | 0.80 |
| D-084 | Wire discipline: billing types in `packages/types/billing.ts` + pydantic contracts in `ai_service/billing/base.py`, dual-schema pinned field-for-field in `apps/web/tests/pins.test.ts` (G-108 discipline); `engine-client.ts` gains `listBillingCatalog()` + a `PaymentRequiredError` (402, dict-detail discrimination — the existing `parseError` VerifyRequiredError precedent). | Every prior cross-boundary wire (PostingSummary, SearchResult, facets) is pinned this way; the 402 CTA payload must be machine-readable for the admin surface. | 0.85 |
## Milestone v0.8 — Ship Anywhere (COMPLETE, Feature, 2026-09-27 → 2026-09-29)
**Scope (founder directive 2026-09-26, options 1–4 confirmed + scope decisions answered 2026-09-26/27):** Release and deployment guides for admins. nextcraft must be installable on any Debian-based Linux server, via every mainstream channel, with release consistency enforced by automation:
1. **Docker container** (Option 1): packaged image with all dependencies pre-loaded, built and uploaded to the Gitea container registry on every phase release
2. **Release binaries** (Option 2, extends the existing v0.4 line): the CLI published as binaries in every release's downloads — x64 (existing) plus arm64, plus .deb packages
3. **CLI full suite** (Option 3): the CLI contains the full application suite for easy onboarding — one binary takes an admin zero → running
4. **Release consistency** (Option 4): pre-commit hooks + Gitea Actions pipelines so every release is made the same way
**Founder scope decisions (2026-09-26/27):** deb + arm64 both added (packaging breadth); docker container unprivileged-by-default with honest sandbox degrade (userns conflict documented, privileged variant documented); Gitea act_runner stood up by this milestone on founder-provided LXC CT 113; one milestone, 6 phases (P0 + P1–P5 + P6 final). Three org-level runners on CT 113 as docker containers (gitea/act_runner, sibling-container mode, host docker.sock, hybrid labels ubuntu-latest + node/python/release).
**D-073 (founder directive, 2026-09-29, post-milestone):** CT 113 root password + the org runner registration token are **RETAINED for future use — rotation declined**. This supersedes the G-206 rotation request carried in the v0.7.6 ship notes. Both are stored in the sanctioned secrets file (`.ciagent/.env.secrets`: gitignored, 0600, the only copies — verified by tree-wide scan). The live-host deploy key (`nc_ci_deploy` → root@10.0.0.105) is likewise persistent per founder approval. Security posture: the credentials never appear in git history, workflows, container layers, or release assets; access remains SSH-key-gated on both hosts.
**Out of scope:** Windows/macOS targets, apt repository hosting (debs ship as release assets), Helm/K8s deployment, Postgres migration, browser E2E tooling.
**Success (milestone-level):** `docker run` / `.deb install` / one-liner binary each take a fresh Debian server to a running verified stack; every release carries binary + checksum + deb + image tag automatically; a push to any branch runs lint/typecheck/tests on the runner fleet; admins have a single deploy guide covering all channels with upgrade + troubleshooting paths.
## Clarify decisions (D-063+, CLARIFY stage full autonomy, 2026-09-27)
| ID | Decision | Rationale | Confidence |
|----|-----------|-----------|------------|
| D-063 | CI push lane = lint + typecheck + **fast lanes only**: `ai:test:fast` (`-m "not real_server and not userns"`), default `web:test` + `cli:test` (E2E stays env-gated out). `real_server`/`userns` NEVER run in job containers. | `userns` is blocked by docker's default seccomp in the sibling-container runners (nested `unshare --user` fails — infeasible, not merely slow); `real_server` is feasible (plain uvicorn processes) but the locked spec wording is "pytest fast lane" and push feedback stays minutes-scale; both lanes remain local/verify-lane surfaces. The CLI E2E (900s real bootstrap) is already env-gated out of `cli:test` (D-055) and stays out | 0.85 |
| D-064 | Tag-triggered Actions workflow is the **exclusive owner** of release assets + registry push (builds x64/arm64 binaries, checksums, debs from the tag checkout; consumes Actions secrets `GITEA_TOKEN`/`REGISTRY_TOKEN` set via the self-serve PUT mechanism). The local ship path stops calling `scripts/release-assets.sh` once the workflow is green; the script remains as the manual fallback (its .env-only token resolution unchanged). | Exclusive ownership kills the double-attach race (ship workflow + tag trigger would both upload the same asset names); the job must build from a clean tag checkout anyway (dist/ artifacts don't exist in the job), so the script's attach-what-exists logic can't serve the workflow; "release consistency enforced by automation" (founder Option 4) means the pipeline, not the founder's box, is the release maker | 0.75 |
| D-065 | Image = `git.coreci.dev/coreci/nextcraft`, tags = the release semver (`v0.7.x`) on every phase ship + `latest` moved to the newest release. No per-push/branch tags. Admin guides pin semver; `latest` is a convenience alias only. | The Gitea registry is owner/repo-scoped (`{instance}/{owner}/{repo}`) — no other image name is addressable, so the naming question has exactly one answer; per-push tags would churn the 32 GB runner disk for zero admin value while the release-consistency scope is phase releases | 0.85 |
| D-066 | The runtime image embeds the CLI binary (`/usr/local/bin/nextcraft`): `docker exec` gives admins in-container `doctor`/`verify`/`guide`; `bootstrap`/`deploy`/`dev` detect the container env and refuse honestly, pointing at the docker-channel paths. | REQ-8-004's "detects container env + suggests volume/privileged facts" is only truthful from inside the container (uid_map/seccomp/userns reality lives there — a host-side CLI cannot probe it without knowing the container name); ~117 MB on an image already carrying a venv + static export is acceptable; refusing beats repo-surgery-in-container (git clone + rebuild inside an immutable image is the anti-pattern the docker channel exists to remove) | 0.80 |
| D-067 | deb = CLI binary at `/usr/bin/nextcraft` + package docs, nothing else. NO systemd unit in the deb — `deploy --service` owns service-unit generation (it bakes the real clone + binary paths; a deb unit would be stale/wrong). No proxy config fragments (HAProxy recipes are per-box, live in the deploy guide). postinst runs `nextcraft doctor` best-effort as an **advisory** summary (output printed, exit never fails the dpkg transaction) + prints next-steps (`doctor` → `deploy --service`). | The deb packages the CLI, not the stack — the unit's paths only exist after deploy chooses a clone dir; postinst failing on missing node/pnpm would brick unattended installs while advisory doctor output still tells the admin what to fix (honest-loud, never blocking — the install.sh degradation pattern) | 0.75 |
| D-068 | Degrade detection = **capability probe**, not markers: at boot + per environment-create the backend attempts a real `unshare --user --map-root-user` (the proven `tests/helpers/userns.py` pattern promoted into the sandbox module); `/.dockerenv` + an `NC_CONTAINER=1` entrypoint var only flavor the remediation text. Degrade semantics: learner environment creation **rejects honestly** (503 + pointer to the documented privileged/seccomp variant) + loud boot notice + doctor surfaces the degraded capability; chat/agents/grading keep working. This supersedes A-101's cwd-jail fallback for deployed schools. | The probe is the truth source — `/.dockerenv` says "docker" but not "userns blocked" (a privileged container passes userns and must NOT degrade; a non-docker host with userns disabled must); unisolated learner code would run as the service user next to provider keys + network — the namespace fabric exists precisely to prevent that, so the A-101 fallback (founder-box convenience) is unacceptable for untrusted multi-learner deployments; reject-with-remediation is the honest degrade | 0.70 |
| D-069 | arm64 = **cross-build on the x64 runner, no QEMU for the build**: the SEA pipeline is arch-agnostic file surgery (esbuild bundle + postject into an arm64 node base binary fetched as a build input); x64 + arm64 binaries and debs build in one release job in minutes. QEMU binfmt (if registered on CT 113) adds an optional `--version` smoke of the arm64 binary; `dpkg-deb` never executes its payload, so the deb needs no emulation. No multi-arch docker image in v0.8 (spec scope is binaries + debs; image stays amd64). RESEARCH verifies the arm64 node base + binfmt availability. | Emulated builds are the slow path and unnecessary: SEA injection never runs the target binary; the only execution need is a smoke, which binfmt provides opportunistically; multi-arch manifests would pull image-arm64 into a scope the spec never asked for | 0.70 |
| D-070 | pre-commit = ONE root `.pre-commit-config.yaml` for the whole monorepo, directory-scoped (ruff on Python paths, eslint/tsc via workspace-scoped invocations), local-only, no network at hook run. On boxes without the toolchain, hooks **fail loudly** — never silently skip; `--no-verify` is the visible deliberate bypass and CI is the authoritative backstop. | A silently-skipped hook lies about release consistency (Option 4's whole point); pre-commit is inherently a repo-root single config — per-workspace configs buy nothing; CI remains the enforcement gate so a bypassed local hook cannot ship unlinted code | 0.80 |
| D-071 | Docker-channel upgrade = `docker pull <tag>` + **recreate** the container (state persists via the `~/.nextcraft` volume) — `deploy`/`bootstrap` never run in-container (D-066 refuse). Binary channel = re-run install.sh + `deploy --service`; deb channel = `dpkg -i` the new deb + `deploy --service` (both idempotent). doctor/guide describe the detected channel's upgrade path. | The image is immutable — deploy-from-repo inside a container is meaningless by construction; the other two channels keep the v0.4.6 idempotent deploy as their upgrade rail (proven on the live box) | 0.85 |
| D-072 | `nextcraft guide` = **offline-first embedded text** (man-page style): channel-aware sections checked into the CLI source (mirrored from deploy/README.md at build time), zero runtime fetches; forge URLs print as pointers to the living runbook. | Fresh-box and degraded installs (no network, no clone) are exactly when guidance is needed; the binary is self-contained by design (SEA) — embedding honors that; runtime fetching would make the guide fail precisely on broken boxes | 0.80 |
## v0.8 research conclusions (Phase 0 RESEARCH, 2026-09-27)
Full evidence + concrete commands live in `.ciagent/RESEARCH.md` (R-1..R-32); architecture in ARCHITECTURE.md's "v0.8 Distribution & Release Architecture" section. Brief conclusions:
1. **Runner image = `gitea/runner:3`** — Docker Hub's `gitea/act_runner` is deprecated/renamed; runner 3.x needs Gitea ≥1.21 (instance probe-verified at 1.26.1). Basic flavour + host docker.sock = exactly sibling mode.
2. **3 containers, one per lane, each with its OWN `/data` volume** (`.runner` registration file — shared = lock refusal + identity loss). Registration token via env at bring-up only (founder chat-provided, reset after fleet green, never in any repo file). Org-level scope comes from the token itself.
3. **Labels via `GITEA_RUNNER_LABELS` env** (passed to both register + daemon): `node:docker://node:24-bookworm`, `python:docker://python:3.11-bookworm`, `release:docker://node:24-bookworm`, `ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest`. Unmatched `runs-on` silently runs on a default image — always pin our labels.
4. **Capacity 1 per runner** (config default; also bounds docker subnet consumption). Fleet concurrency = 3.
5. **Cache requires `cache.host` + published port** on dockerized runners (per-job networks make the auto-detected cache address unreachable — actions/cache ETIMEDOUTs otherwise); 32 GB guarded by cache caps + health_check disk floor + host prune cron. `actions/cache@v4` works on runner 3.x (cache v2 API, stock action self-patched).
6. **Sibling-mode path alignment NOT needed for our jobs** (no bind_workdir; `docker build` TARS context over the API); documented as the gotcha if a future workflow bind-mounts.
7. **Push lane = repo's own scripts, no turbo lint wholesale** (it pulls venv-dependent ai-service tasks): node lane (corepack pnpm 12.3.4, eslint+tsc per TS workspace, web/cli node:test) + python lane (bootstrap.sh venv, ruff, mypy, FAST=1 test.sh). D-063's fast-only rule is both policy and physical (docker default seccomp blocks nested unshare). ubuntu-latest lane = dispatch verification only.
8. **Release lane = tag-triggered exclusive owner**: builds from the tag checkout, stamps with `gitea.ref_name`, attaches assets via the same REST API scripts/release-assets.sh uses (NOT actions/upload-artifact); reuses the API in-workflow, script untouched as manual fallback (.env-only token resolution preserved).
9. **Automatic job GITEA_TOKEN cannot publish packages (upstream-confirmed)** — registry push needs the `REGISTRY_TOKEN` PAT secret (self-serve PUT first, founder UI fallback). `docker/build-push-action` unnecessary: plain login+build+push for a single-arch amd64 image.
10. **Dockerfile**: web-export stage (NEXT_PUBLIC_AI_SERVICE_URL=self, build-time-only) + venv stage (prod deps) + `python:3.11-slim-bookworm` runtime (~400-500 MB incl. the ~117 MB CLI embed, D-066): non-root, urllib `/health` healthcheck (no curl in slim), `VOLUME /home/nextcraft/.nextcraft` (state root is `~/.nextcraft` by config default, env-overridable with ~-expansion), zero AI_* env at build, all keys optional at runtime (G-11).
11. **deb**: `dpkg-deb --root-owner-group -Zxz` staging build (never executes payload — cross-arch free), Version = tag minus `v`, empty Depends + `Recommends: nodejs, python3, git, util-linux` (soft deps), postinst advisory doctor (exit 0 always), no unit, no proxy fragments (D-067).
12. **arm64 cross-build QEMU-free**: the only arch-specific step in build-binary.mjs is `copyFileSync(process.execPath, out)` — replaced by a copy from the official arm64 node tarball (probe: nodejs.org dist 200, 30 MB); esbuild bundle + postject ELF-note injection are arch-agnostic file surgery. SEA blob cross-arch needs a P3 probe (config has no useCodeCache/useSnapshot — good sign); binfmt `--version` smoke optional per D-069.
13. **D-068 degrade**: probe promoted verbatim from tests/helpers/userns.py into `ai_service/sandbox/capability.py`; boot notice copies the main.py G-11 warning pattern; per-create rejects 503 via a new SandboxUnavailableError arm beside the existing PoolFullError→503 (today it 500s); doctor upgrades `which unshare` to a real userns attempt. Markers only flavor text.
14. **install.sh arch-resolve**: `x86_64`→x64, `aarch64|arm64`→arm64; sha256+version integrity gates stay byte-identical per arch.
15. **Admin-guide structure** (REQ-8-005): per-channel chapters (docker/deb/binary) each ending in the SAME verification checklist, an upgrade matrix table (D-071 semantics), symptom/cause/fix troubleshooting tables, where-state-lives table per channel, sibling-mode accepted-risk row, SEA/corepack/cache-egress probe outcomes recorded. Ecosystem scan: Ollama installer UX, Tailscale per-channel doc skeleton, Gitea/act_runner's own dual distribution, Miniflux binary+image+deb shape.
## Milestone v0.7 — Test Harness Review (COMPLETE, shipped as v0.6.6, 2026-09-26)
**Scope (founder directive post-v0.6, scope decisions answered 2026-09-26):** Once v0.6 shipped, review the entire test harness — make sure the right tests are being run, make them effective, cut tests that provide no real value, review all warnings, and address all of it within this single milestone. NFR refinement-only: zero `feat:` commits (D-001 purity gate at final phase).
@@ -230,6 +359,7 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
| ID | Decision | Rationale | Outcome |
|----|----------|-----------|---------|
| D-085 | v0.10 = B2B Employer Network (auto-decided at SPECIFY, full autonomy D-007; founder scope confirmed 2026-09-30) | Real employer auth/accounts + self-serve: registered employer accounts with sessions, employer-entity claim/verify, self-serve posting management (draft→published→closed on own entities), self-serve checkout (v0.9 catalog with employer-principal buyers, superseding D-076 operator mediation), employer dashboard real-data conversion. Rationale: A-407 sequences employer auth post-payments (payments shipped v0.9); A-401/D-076/D-079 deferred self-serve precisely pending this; the employer dashboard is the last mock-only surface; CIRR tracking (REQ-F-020) needs employer-side data. Confidence 0.80 ≥ threshold 0.60 (D-007). | Milestone scope locked at SPECIFY; tags on v0.9.x line; feature milestone; branch milestone/v0.10-b2b-employer-network |
| D-001 | Milestone v0.1 = UI/UX prototype only, no business logic | Founder directive: validate UX before building backend. Prototype must be agreed upon before proceeding to business logic. | Scope locked to frontend surfaces with mock data |
| D-002 | MAJOR 0 until MVP released | Founder directive: remain on v0.x until MVP is validated. Signals pre-release status. | All tags are v0.x.y until MVP agreement |
| D-003 | TypeScript monorepo (pnpm/turborepo) + Next.js | Unified codebase for all 4 surfaces. Shared component library, types, mock data. Next.js App Router for route-based surface separation. Python AI services deferred to later milestones. | Monorepo structure with apps/web + packages/* |
@@ -246,6 +376,7 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
| D-014 | Provider-agnostic LLM layer; ollama-cloud as initial provider | OpenAI-compatible client abstraction with pluggable providers: ollama-cloud (https://ollama.com/v1, default), local OpenAI-compatible endpoint, deterministic mock (tests/CI). Keys in gitignored .ciagent/.env.secrets, never in code or commits. | apps/ai-service llm package with 3 providers; default=ollama-cloud |
| D-015 | All six agents implemented as real LLM services; engines mocked | Coach/Tutor/Mentor fully real. Lab/Assessor/Proctor are real LLM logic over mock inputs (simulated telemetry, pre-baked artifacts) since sandbox fabric, assessment engine, and identity verification are v0.3+. Consistent with v0.1's mock-data approach. | REQ-F-001..006 complete in v0.2; real engines deferred to v0.3+ |
| D-016 | v0.4 = Distribution & Bootstrap CLI (founder directive supersedes previously-named v0.4 seams) | Founder directive 2026-09-12: focus this milestone on streamlining install, a bootstrap CLI with a one-liner install script, ongoing release binaries. Real server STT/TTS, KYC, design/simulation envs, seq-lease move to v0.5. | Milestone scope locked at SPECIFY; binary = CLI-only, linux x64 |
| D-074 | v0.9 = Payment Engine (auto-decided at SPECIFY, full autonomy; founder scope question unanswered post-v0.8) | Activates REQ-F-018 as REQ-9-001..005: posting packages (single/bundle/enterprise), talent-access plans, subscription tiers — checkout, provider webhooks, entitlement enforcement on posting creation, subscription lifecycle. Provider-agnostic protocol, mock-first (D-014 pattern). Rationale: ≥1,000 placements requires a monetized self-serve employer side; payments is the only high-priority REQ deferred since v0.3 that unblocks employer self-serve (A-407) and paid postings precede CIRR tracking. Confidence 0.80 ≥ threshold 0.60 (D-007). | Milestone scope locked at SPECIFY; tags on v0.8.x line; feature milestone; branch milestone/v0.9-payment-engine |
| D-047 | v0.6 = Marketplace Engine (auto-decided at SPECIFY, full autonomy; founder scope question left unanswered 2026-09-21) | Activates REQ-F-011..016 as REQ-6-001..006: real marketplace backend (aggregation, tagging, vector search, enrichment, resume parsing, SEO pages). Rationale: the guiding outcome (≥1,000 placements via the marketplace) runs through the only surface with zero backend; every prior milestone fed the credential side, placement side is the bottleneck. Confidence 0.80 ≥ threshold 0.60 (D-007). External 3M+ job corpus scaled to pilot (adapter contract preserves the full-scale path). | Milestone scope locked at SPECIFY; tags on v0.5.x line; feature milestone |
| D-039 | Single-port deploy + unattended dev (v0.3.6 hotfix, founder directive) | Only :8420 reachable behind HAProxy; site was down (domain root hit the API 404). Web app = static export served same-origin by the ai-service (`AI_WEB_STATIC_DIR`); `dev -d`/`stop`/`log` daemonize ops; durable state (DB, sandboxes, pid/log) moves to `~/.nextcraft/` out of the repo. | One port serves UI+API; unattended deploy recipe in deploy/README.md |
@@ -255,4 +386,4 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
Nextcraft is being built by a solo founder using the CIAgent v0.7.0 autonomous pipeline. The vision document defines a 36-month, 3-phase roadmap to ≥1,000 placements. The first CIAgent milestone (v0.1) is intentionally scoped to UI/UX only — validating the product vision through interactive prototypes before any backend or business logic investment.
The four surfaces (Learner, Marketplace, Employer Dashboard, Admin) map directly to the four audiences in the vision: learners, employers, marketplace operators, and platform administrators. The prototype will demonstrate the complete user journey across all surfaces with realistic mock data reflecting the AI-era competency stacks, AI-orchestration job listings, and artifact+process trace+oral defense credential model.
The four surfaces (Learner, Marketplace, Employer Dashboard, Admin) map directly to the four audiences in the vision: learners, employers, marketplace operators, and platform administrators. The prototype will demonstrate the complete user journey across all surfaces with realistic mock data reflecting the AI-era competency stacks, AI-orchestration job listings, and artifact+process trace+oral defense credential model.
+154 -4
View File
@@ -1,6 +1,146 @@
# Nextcraft — REQUIREMENTS.md
## v0.7 Requirements (ACTIVE — Test Harness Review, NFR refinement-only)
## v0.11 Requirements (Complete — Placement Tracking and Audit, Feature; shipped as v0.10.6)
**Milestone type: Feature** — CIRR-style placement tracking and audit (REQ-F-020 activated per D-098). Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`. REQ-F-020 activated as REQ-11-001..005: the placement store + evidence model (REQ-11-001), the employer-side application state machine with audit-logged transitions (REQ-11-002), the placement reporting surfaces (REQ-11-003), the audit-grade export (REQ-11-004), learner-side placement visibility (REQ-11-005). Mock-first doctrine holds (no external systems, no third-party auditor integration — the export is the deliverable); tests never call a cloud.
### Placement Store + Evidence Model
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-001 | Placement store + evidence model: `ai_service/placements/` module (D-031 extend pattern — 9th D-027 store family, shared DB file, `create_all` idempotent): `PlacementRow` (id, learner_id, employer_id, posting_id, application_id, role_title, start_date, status, created_at, decided_at) with partial UNIQUE on `application_id` WHERE status='active' — one ACTIVE placement per application, revoked rows stay in history (D-102, the G-316 conditional-uniqueness shape); `PlacementEventRow` (append-only ledger: application_id nullable+indexed, placement_id nullable, event_type, actor_type, actor_ref, payload, prev_hash, hash, created_at) — one global hash chain, sha256 over canonical JSON + prev_hash, `UNIQUE(prev_hash)` fork backstop (D-101), tamper-evident by append-only discipline + hash chain (the chain makes silent rewrites detectable, G-301-class integrity); evidence links resolved from existing rows only (posting ref from postings store, application ref from applications store, employer ref from the claimed entity — G-302 doctrine: never trust denormalized copies); SQLModel typed access, Protocol + SQLite impl pair (the employer-store pattern), tz-normalization, fail-closed on unknown inputs | critical | 1 | complete (v0.10.1) |
### Application State Machine
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-002 | Employer-side application state machine: the v0.6 `ApplicationRow.status` extends from `submitted`-only to the hiring lifecycle (submitted → in_review → interview → offer → hired / rejected / withdrawn — forward-only with skip-forward, terminals have no outgoing edges, D-099; withdrawn learner- or operator-initiated, all others employer-principal-driven); transitions via per-target employer routes under `/v1/employers/applications` (`/{id}/in-review`, `/{id}/interview`, `/{id}/offer`, `/{id}/reject`, `/{id}/hire` with `{role_title, start_date}` — D-105) gated by the employer session + claimed-entity authorization (the posting's employer must be the session's claimed entity — 403 otherwise, G-311 gate order); every transition appends the placement ledger in the SAME transaction as the status write (application events + placement events on hired — D-100); rejected/withdrawn/hired are terminal with different semantics (hired creates the PlacementRow + role_title + start_date payload); admin routes retain operator oversight (admin transition + audit, the D-094 lane); learner read access stays the existing surface (their own application status history); G-319 scoping: learner identifiers become visible on employer surfaces only for applications on the session's claimed-entity postings (the own-postings list stays counts-only) | critical | 2 | complete (v0.10.2) |
### Placement Reporting
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-003 | Placement reporting: aggregate placement counts computed from the ledger (per employer, per cohort month, cumulative toward the ≥1,000 guiding outcome); per-employer placement view (the employer dashboard gains a placements section — their hired applications with role/start date, honest empty states); per-learner view (the learner's application list gains outcome states); admin placement overview (the moderation page's network section gains aggregate counts + per-employer placement lists — the operator lane); counts join through existing stores (employer identity via claimed entities, learner via applications, posting via postings) | high | 3 | complete (v0.10.3) |
### Audit Export
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-004 | CIRR-style audit export: admin-gated export routes (`GET /v1/placements/admin/export` + `GET /v1/placements/admin/export/summary`, text/markdown — D-108) producing the third-party-auditor dataset: (a) a cohort snapshot (all placements with employer refs, role titles, start dates, evidence chains); (b) per-placement evidence bundles (posting record, application record, state-transition history, decision provenance — each link resolved from source rows, never denormalized copies); (c) a methodology document (how a placement is defined, counted, and verified — the counting rules an auditor needs); (d) the event-ledger chain with head hash (tamper-evidence for the export itself); output as JSON + a human-readable summary; honest scoping (pilot scale: the export covers every placement in the DB, unbounded by date); export integrity test (re-export reproduces identical event chains + head hash, generated_at excluded — D-108) | critical | 4 | complete (v0.10.4) |
### Learner Placement Visibility
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-005 | Learner-side placement visibility: the learner application surface gains outcome visibility via `GET /v1/marketplace/applications/{application_id}?learner_id=` (current status + full timestamped state history + placement record when hired — D-104) and `POST /v1/marketplace/applications/{application_id}/withdraw` (learner-gated per the D-043 composition: G-5 allowlist → verified 18+ → apply-rate window); placement confirmation (when hired, the learner sees the placement record — role title, employer, start date — sourced from the same ledger); no learner-initiated edits beyond withdrawn (status changes are employer/admin-driven only); the talent profile's placement badge stays absent until a placement exists (honest, the A-304 doctrine — no badge on the v0.1 mock pages) | medium | 5 | complete (v0.10.5) |
---
## v0.10 Requirements (Complete — B2B Employer Network, Feature; shipped as v0.9.6)
**Milestone type: Feature** — real employer auth/accounts + employer self-serve (the B2B employer network; activated per D-085). Shipped on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`. All 5 requirements complete: the employer auth foundation with scrypt credentials + hashed sessions + throttles (REQ-10-001), the entity claim/verify flow with advisory evidence + admin approval (REQ-10-002), the dashboard real-data conversion with honest states (REQ-10-003), self-serve postings through the shared D-091 entitlement gate byte-identical with the admin path (REQ-10-004), self-serve checkout over the v0.9 catalog through the real webhook path + the admin network overview (REQ-10-005). Auth is session-based with hashed credentials (mock-first doctrine: no external auth provider, tests never call a cloud); admin operator routes are retained unchanged (D-043 composition untouched).
### Employer Auth Foundation
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-001 | Employer auth foundation: `ai_service/employers/` module (D-031 extend pattern): employer account store (8th D-027 family — `employer_accounts`, `employer_sessions`, `employer_claims`, `employer_audit_events` tables, D-095) with SQLModel typed access; password hashing via stdlib `hashlib.scrypt` (D-086 — zero new deps, per-password random salt, self-describing params); session tokens as server-side store rows with the token stored hashed, fixed 7-day TTL evaluated lazily, revoke-on-logout (D-087); register/login/logout/me routes (`/v1/employers/auth/*`, Bearer session transport per D-090); rate limiting on auth routes (D-088 — per-email failed-login window + global attempt cap, the G-5 in-memory pattern); credentials + secrets env-only (D-014); fail-closed when unconfigured | critical | 1 | complete |
### Employer Entity Claim
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-002 | Employer-entity claim/verify: an authenticated employer account claims an existing `EmployerRow` entity (the v0.6 ingested data) via a claim route; claims enter a pending state with recorded evidence (email-domain vs entity-website comparison — advisory signal, D-089) and activate via admin approval at pilot; claims recorded with provenance (`employer_claims`); one account : one entity (UniqueConstraint); admin routes for claim review/approve/reject (operator oversight retained, audit-logged per D-089); claimed-entity authorization: employers can only manage postings/purchases for their own entity | critical | 2 | complete |
### Employer Account Surface
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-003 | Employer dashboard real-data conversion: the employer surface switches from v0.1 mock imports to runtime client fetches (A-406 pattern — AbortController, honest loading/error/empty states) via the engine-client employer section + sessionStorage session state (D-092); employer auth UI (login/register pages, session-gated dashboard shell, honest logged-out states); posting management reads the employer's own postings via the authenticated surface (draft → published → closed lifecycle visible); employer profile read from the claimed entity (enrichment retained); talent search + candidate profile pages stay the v0.1 mock (no candidate backend exists — honest out-of-scope, D-092); wire types in `packages/types/employer.ts` dual-schema pinned (G-108 discipline, D-093) | critical | 3 | complete (v0.9.3, release 951) |
### Self-Serve Posting Management
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-004 | Self-serve posting management: employer-principal posting routes (`/v1/employers/postings/*`) — create draft, edit own drafts, publish own drafts, close own postings, list own postings with status facets; authorization: the entitlement check composes AFTER the employer session gate (401/403 precede any 402 — the admin-gate-first order preserved, G-311) with the 402 CTA unchanged, D-080 consumption model intact, via the shared api-layer helper extracting the v0.9 gate (D-091 — no duplication); posting creation consumes from the employer's own packages (the v0.9 entitlement check parameterized by the session's employer_id instead of request body); the admin posting routes remain (operator path unchanged, provenance-recorded); 18+ gate N/A (employer surface is not learner-facing; the D-043 learner gates untouched, D-097) | high | 4 | complete (v0.9.4, release 952) |
### Self-Serve Checkout
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-005 | Self-serve checkout + admin network overview: employer-principal checkout (`/v1/employers/billing/checkout`) creating provider sessions for the catalog (the v0.9 billing module reused — provider protocol, webhooks, entitlement grants unchanged; buyer = session employer instead of operator mediation, superseding D-076 while admin routes stay as the operator fallback; D-091 composes the existing checkout route logic with the session principal); entitlement view for the employer's own purchases/subscriptions (current package, posts used/limit, expiry — honest states); pricing page CTAs convert from contact-path to employer-login-path (superseding D-079); admin surface gains the network overview on the marketplace moderation page (employer accounts list, claim queue with evidence signals, per-account entity + entitlement state — the operator oversight lane, D-094) | critical | 5 | complete (v0.9.5, release 953) |
---
## v0.9 Requirements (Complete — Payment Engine, Feature; shipped as v0.8.6)
**Milestone type: Feature** — payment processing and subscription management (REQ-F-018 activated per D-074). Shipped on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`. All 5 requirements complete: the billing module + 7th D-027 store + HMAC webhook core (REQ-9-001), the catalog + pricing surface (REQ-9-002), the checkout/webhook flow with per-session exactly-once grants (REQ-9-003), posting entitlement enforcement with honest 402s (REQ-9-004), the full subscription lifecycle + queryable audit log (REQ-9-005). — payment processing and subscription management (REQ-F-018 activated per D-074). Tags on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`. Provider-agnostic protocol, mock-first (D-014 pattern): tests never call Stripe; the real provider drops in by env config without API changes.
### Billing Foundation
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-9-001 | Billing foundation: `ai_service/billing/` module (D-031 pattern — extend ai-service, no new apps): `PaymentProvider` protocol (create checkout session, retrieve session, cancel subscription, upgrade/downgrade subscription, list subscription events) + deterministic mock provider (approves-on-policy, synchronous state machine); SQLite billing store (7th D-027 family — `packages`, `plans`, `subscriptions`, `subscription_events`, `checkout_sessions` tables) with SQLModel typed access; webhook signature verification (HMAC, per-boot secret default — the v0.4.7 ingest-token pattern); provider selection via `AI_PAYMENT_PROVIDER=mock\|stripe` env (keys env-only, never committed); test-only mode is the default (mock) so the stack runs with zero keys | critical | 1 | complete |
### Catalog + Pricing Surface
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-9-002 | Package catalog + real pricing surface: seeded package catalog (single posting, bundle ×5, enterprise-unlimited tiers + talent-access plans: basic/pro/enterprise per REQ-017's existing pricing-page tiers) exposed via `/v1/billing/catalog`; the pricing page switches from mock imports to runtime client fetch (A-406 pattern) with honest loading/error states; package features/limits data-driven from the catalog (no hardcoded tier copy drifting from backend); build-time static metadata retained (A-411) | critical | 2 | complete |
### Checkout Flow
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-9-003 | Checkout + webhook flow: `POST /v1/billing/admin/checkout` (admin token-gated per D-076 — operator buys on an employer's behalf; buyer recorded as operator ref + employer ref) creates a provider checkout session for a catalog item; mock provider renders an in-app confirmation path; stripe provider redirects to Stripe Checkout (env-gated, never in tests); `POST /v1/billing/webhook` receives provider events with HMAC signature verification (reject 401 on bad signature — never process unsigned), idempotent event processing (event_id dedupe + per-session grant idempotency per G-303), entitlement granted on `checkout.completed` with state resolved from local rows only (G-302 — event payloads are untrusted ID references); webhook replay tolerance (at-least-once semantics — the A-309 seq pattern applied to billing) | critical | 3 | complete |
### Entitlement Enforcement
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-9-004 | Posting entitlement enforcement: posting creation (admin API route per A-401) checks the employer's active package — posting limit reached → honest 402 with upgrade-CTA payload (pointing at catalog/checkout); unlimited/enterprise bypass; expired/cancelled package degrades to the same 402 path; entitlement check composed after the admin token gate (operator route — D-043 learner gate composition untouched); admin surface shows per-employer entitlement state (active package, posts used/limit, expiry) in the existing admin marketplace moderation view | high | 4 | complete |
### Subscription Lifecycle
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-9-005 | Subscription lifecycle + billing admin: subscription states (trialing → active → past_due → cancelled/expired) with provider-driven transitions (webhook events `invoice.paid`, `invoice.payment_failed`, `subscription.cancelled`, `subscription.renewed`); renewals extend expiry idempotently; admin routes for manual grant/revoke/complement (operator remediation for failed webhooks — recorded as manual events with provenance); billing event log with full audit trail (every provider event + manual action stored, queryable); honest 402 + grace handling on payment failure (past_due grace window before downgrade) | high | 5 | complete |
## v0.8 Requirements (Complete — Ship Anywhere, Feature; shipped as v0.7.6)
**Milestone type: Feature** — new distribution channels (docker image, deb packages, arm64 binaries) and release automation (Gitea Actions, pre-commit). Tags on the v0.7.x line (P0 → v0.7.0 … P6 → v0.7.6 = milestone release); branch `milestone/v0.8-ship-anywhere`.
### Release Consistency Rails
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-8-001 | Release-consistency rails: three org-level Gitea act_runners stood up on founder LXC CT 113 (10.0.0.113, docker containers in sibling-container mode via host /var/run/docker.sock, hybrid labels ubuntu-latest/node/python/release, org-level coreci scope, registration token founder-provided and reset after fleet green), `.gitea/workflows/` lint+typecheck+test pipeline on push (v0.7 toolchain: eslint, ruff, mypy, pytest fast lane, web/cli node:test; CI test lanes = fast only per D-063: `ai:test:fast` `-m "not real_server and not userns"`, default web/cli node:test, CLI E2E env-gated out — real_server/userns never run in job containers), tag-triggered release workflow as the exclusive owner of release assets + registry push (D-064; local release-assets.sh remains manual fallback), single root `.pre-commit-config.yaml` (ruff + eslint + tsc local-only fast hooks, directory-scoped whole-monorepo, no network, secrets never in hooks or workflow env; hooks fail loudly on missing toolchain, never silently skip — D-070; CI is the authoritative backstop), runner cache-hygiene (docker prune cron + cache caps, 32 GB disk), hello-world dispatch verification across all three labels | critical | 1 | complete |
### Container Channel
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-8-002 | Docker image channel: multi-stage Dockerfile (deps layer: pnpm store + pip wheels pre-loaded; build layer: web static export with NEXT_PUBLIC_AI_SERVICE_URL=self; runtime: uvicorn :8420 serving API + export — single-port topology preserved; runtime image embeds the CLI binary at /usr/local/bin/nextcraft for in-container doctor/verify/guide — D-066), unprivileged default + honest degrade (capability probe at boot + per create — a real `unshare --user --map-root-user` attempt as the truth source, `/.dockerenv`/NC_CONTAINER only flavor remediation text; degraded = learner environment creation rejects 503 with privileged/seccomp remediation pointer + loud boot notice + doctor surfacing — D-068), healthcheck on /health, `~/.nextcraft` state volume, image pushed to the Gitea container registry as `git.coreci.dev/coreci/nextcraft` tagged semver release `v0.7.x` + `latest` on newest release (D-065), REGISTRY_TOKEN as Gitea Actions secret (self-serve PUT attempt first, founder UI fallback) | critical | 2 | complete |
### Debian-Native Channel
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-8-003 | Debian packaging: arm64 SEA binary + sha256 sidecar cross-built on the x64 runner without QEMU for the build (arch-agnostic esbuild+postject file surgery into an arm64 node base; optional binfmt `--version` smoke — D-069), .deb packages (dpkg-deb) for x64+arm64 = CLI binary at /usr/bin/nextcraft + package docs only, NO systemd unit (deploy --service owns unit generation) and no proxy fragments; postinst runs `nextcraft doctor` best-effort advisory (never fails dpkg) + prints next-steps (D-067); all attached to every release's downloads by the tag-triggered workflow (D-064); install.sh arch-gate becomes arch-resolve (x64 + arm64 binary paths); deb install + binary install both end at `nextcraft doctor` verify | high | 3 | complete |
### CLI Onboarding
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-8-004 | CLI full-suite onboarding: `nextcraft` alone takes an admin zero → running — doctor gains deb/docker-channel awareness (detects container env + suggests volume/privileged facts; detects deb install + knows its binary location; embedded in-container binary per D-066), `nextcraft guide` surfaces the admin runbook channel-appropriately as offline-first embedded text with forge pointers (D-072), docker-channel upgrade = docker pull + recreate (deploy/bootstrap refuse in-container — D-066/D-071), onboarding glue verified by an E2E test per channel (binary one-liner path exists from v0.4; docker + deb paths new) | high | 4 | complete |
### Admin Guides
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-8-005 | Admin release & deployment guides: deploy/README.md expanded into the full admin runbook — three install channels (docker / deb / binary+install.sh) first-install recipes, per-channel upgrade paths (docker pull + recreate / deb reinstall via dpkg -i + deploy --service / install.sh re-run + deploy --service, all idempotent — D-071), HAProxy + systemd recipes preserved, troubleshooting tables extended (runner, registry, docker degrade, deb), every recipe backed by a test or verified live-box evidence (house rule) | critical | 5 | complete |
## v0.7 Requirements (Complete — Test Harness Review, NFR; shipped as v0.6.6)
**Milestone type: NFR — refinement-only (D-001 purity gate: zero `feat:` commits).** Review the entire test harness: make sure the right tests are being run, make them effective, cut tests that provide no real value, eliminate all warnings, and address the findings within this single milestone. Founder scope decisions locked 2026-09-26: ESLint included (D-048), recharts 2→3 included (D-049), source-pin triage cut-trivial-keep-real (D-050), mypy included (D-051).
@@ -256,9 +396,9 @@
| ID | Description | Priority | Milestone | Status |
|----|-------------|----------|-----------|--------|
| REQ-F-017 | Identity verification and age-gating (16+/18+) — real KYC backend | high | v0.5 | activated → complete (REQ-5-003/004) |
| REQ-F-018 | Payment processing and subscription management | high | v0.3+ | deferred |
| REQ-F-018 | Payment processing and subscription management | high | v0.9 | activated → REQ-9-001..005 |
| REQ-F-019 | Human tutor marketplace (third-party courses) | medium | v0.4+ | deferred |
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | deferred |
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | activated → REQ-11-001..005 |
---
@@ -278,6 +418,16 @@
## Traceability Matrix
### v0.11 (complete)
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-11-001 | 1 | complete |
| REQ-11-002 | 2 | complete |
| REQ-11-003 | 3 | complete |
| REQ-11-004 | 4 | complete |
| REQ-11-005 | 5 | complete |
### v0.6 (complete)
| Requirement | Phase | Status |
@@ -372,4 +522,4 @@
| REQ-025 | 5 | complete |
| REQ-026 | 6 | complete |
| REQ-027 | 6 | complete |
| REQ-028 | 6 | complete |
| REQ-028 | 6 | complete |
+1605
View File
File diff suppressed because it is too large Load Diff
+70 -2
View File
@@ -2,7 +2,11 @@
## Overview
**Milestone v0.7 — Test Harness Review (COMPLETE, shipped as v0.6.6, 2026-09-27).** NFR refinement-only milestone reviewing the entire test harness: coverage/timeouts/markers wired, trivial tests cut with recorded triage, duplicated infra consolidated, 1,716 warnings/run eliminated to zero, ESLint+RUF100+mypy toolchain completed, recharts 3. Founder scope decisions D-048..D-051; clarify D-052..D-061; grill G-101..G-110. Tags on the v0.6.x line (v0.6.0 phase 0 → v0.6.6 final = milestone release); branch `milestone/v0.7-test-harness-review`.
**Milestone v0.11 — COMPLETE (shipped as v0.10.6, 2026-10-22).** Feature milestone: CIRR-style placement tracking and audit (REQ-F-020 activated per D-098) — the machinery that makes the guiding outcome (≥1,000 placements, CIRR-style third-party audit) measurable: the placement store + hash-chained evidence ledger, the employer-side application state machine (submitted → in_review → interview → offer → hired/rejected/withdrawn), placement reporting surfaces (per-employer, per-learner, aggregate toward ≥1,000), the audit-grade export (cohort snapshot + evidence bundles + methodology + chain head), learner-side placement visibility. Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`.
**Milestone v0.10 — B2B Employer Network (COMPLETE, shipped as v0.9.6, 2026-10-01).** Feature milestone: real employer auth/accounts + self-serve (D-085). Employers become principals — registered accounts with sessions, employer-entity claim/verify, the employer dashboard converting from the v0.1 mockup to real data, self-serve posting management with the v0.9 entitlement enforcement intact, and self-serve checkout over the v0.9 billing catalog (superseding D-076 operator mediation; admin operator routes retained as fallback + oversight). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`.
**Milestone v0.9 — Payment Engine (COMPLETE, shipped as v0.8.6, 2026-09-30).** Payment processing and subscription management (REQ-F-018 activated per D-074): the billing module (provider protocol mock+stripe, 7th D-027 store, HMAC webhooks), catalog + pricing surface, operator-mediated checkout, entitlement enforcement, full subscription lifecycle. Founder scope decision D-074; clarify D-075..D-084; grill G-301+. Tags on the v0.8.x line (v0.8.0 phase 0 → v0.8.6 final = milestone release); branch `milestone/v0.9-payment-engine`.
**Milestone v0.6 — Marketplace Engine (COMPLETE, shipped as v0.5.6, 2026-09-26).** The real marketplace backend behind the existing surfaces: job posting backend + aggregation adapter (REQ-6-001), AI tagging (REQ-6-002), semantic vector search + match scoring (REQ-6-003), company enrichment + resume parsing (REQ-6-004/005), SEO programmatic pages (REQ-6-006). Scope decision D-047 (auto-decided at SPECIFY, full autonomy). Tags on the v0.5.x line (v0.5.0 phase 0 → v0.5.N final = milestone release); branch `milestone/v0.6-marketplace-engine`.
@@ -28,8 +32,72 @@
---
## v0.9 (Complete — Shipped as v0.8.6, 2026-09-30)
Payment processing and subscription management (REQ-F-018 activated per D-074, auto-decided at SPECIFY under full autonomy): posting packages (single/bundle/enterprise), talent-access plans, subscription tiers with real checkout, provider webhooks (HMAC-verified, idempotent), posting-creation entitlement enforcement, and a full subscription lifecycle (renew/cancel/upgrade/past_due grace) — all behind a provider-agnostic `PaymentProvider` protocol, mock-first (D-014 pattern; tests never call Stripe). The pricing page (REQ-017) gains its real backend. Out of scope: employer auth/accounts (B2B network — post-payments per A-407), multi-currency, tax/VAT beyond passthrough, invoice PDFs, CIRR tracking (REQ-F-020). Tags on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`.
## Phase List
### v0.11 (complete — shipped as v0.10.6, 2026-10-22)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | complete (v0.10.0) | — | — | Specify (D-098), clarify, research, plan, grill, MVP/UX check complete (shipped as v0.10.0) |
| 1 | Placement store + evidence ledger | complete (v0.10.1) | 0 | REQ-11-001 | `ai_service/placements/` module: 9th D-027 store (PlacementRow + PlacementEventRow, hash-chained append-only ledger), evidence resolved from source rows only (G-302 doctrine), `create_all` idempotent on the shared DB file, Protocol + SQLite pair |
| 2 | Application state machine | complete (v0.10.2) | 1 | REQ-11-002 | Employer routes extend the v0.6 application status from submitted-only to the full hiring lifecycle; claimed-entity authorization (403 cross-entity, G-311 gate order); every transition ledger-logged; hired creates the placement; admin oversight retained |
| 3 | Placement reporting | complete (v0.10.3) | 2 | REQ-11-003 | Aggregate counts from the ledger (per employer, per cohort month, cumulative toward ≥1,000); employer dashboard placements section; learner outcome states; admin network overview counts |
| 4 | Audit export | complete (v0.10.4) | 3 | REQ-11-004 | Admin-gated export: cohort snapshot + per-placement evidence bundles + methodology doc + chain head hash; JSON + human-readable summary; re-export reproducibility test |
| 5 | Learner placement visibility | complete (v0.10.5) | 4 | REQ-11-005 | Learner application outcomes + full state history; hired learners see their placement record; honest no-badge-until-placed (A-304) |
| 6 | Final review + ship | complete (v0.10.6) | 1-5 | — | Review SHIP — 0 P0s, 2 P1 doc-level findings dispositioned with recorded follow-ups (the PLAN Must-Have test_e2e_audit.py name vs test_export.py::test_e2e_audit_proof; the D-112 census omitting the employers/store.py two-line audit-vocabulary addition); audit PASS — D-112 reconstruction clean (27 branch commits vs the census), branch/commit discipline green; ship: v0.10.5 tagged at the ship per the recorded deferral (e32e54e7aa), milestone merged to main (PR #6), v0.10.6 tagged at the merge, release lanes fired, milestone branch deleted |
---
## Phase List (v0.10 — complete)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | complete (v0.9.0) | — | — | Specify (D-085), clarify (D-086..D-097), research (R-55..R-66), plan, grill (G-311..G-320), MVP/UX check complete (shipped as v0.9.0, release 948) |
| 1 | Employer auth foundation | complete (v0.9.1) | 0 | REQ-10-001 | `ai_service/employers/` module: account store (8th D-027 family, 4 tables) + scrypt hashed credentials (params pinned) + hashed server-side sessions (7-day lazy TTL) + register/login/logout/me routes + dual D-088 auth throttles + CORS Authorization preflight + PII scrub; fail-closed secrets; tests never call a cloud (shipped as v0.9.1, release 949) |
| 2 | Employer entity claim | complete (v0.9.2) | 1 | REQ-10-002 | Claim route over existing EmployerRow entities with stdlib-only claim_evidence normalization (advisory-only per D-089/A-304); G-316 partial UNIQUE index closes the pending-claim TOCTOU; one account : one entity bound at approve with same-transaction audit provenance; admin claim review/approve/reject — 5 operator routes fail-closed, 403 matrix 20 probes; rejection frees entities; claimed-entity authorization enforced (shipped as v0.9.2, release 950) |
| 3 | Employer account surface | complete (v0.9.3) | 2 | REQ-10-003 | Employer dashboard real-data conversion (mock imports → runtime fetches, A-406 pattern); login/register UI; session-gated shell; own-postings view; dual-schema wire pins (shipped as v0.9.3, release 951) |
| 4 | Self-serve posting management | complete (v0.9.4) | 3 | REQ-10-004 | Employer-principal posting routes with own-entity authorization; D-091 shared entitlement gate (one helper, byte-identical principal gates); create/edit/publish/close on the claimed entity with G-311 gate order, G-317 provenance stamps, G-318 own-drafts guard + admin re-tag parity; PostingManager honest 402 CTA / 403 claim states per G-320b; admin routes retained (shipped as v0.9.4, release 952) |
| 5 | Self-serve checkout + admin overview | complete (v0.9.5) | 4 | REQ-10-005 | Employer-principal checkout over the v0.9 catalog via the REAL webhook path with buyer = the session's claimed entity (supersedes D-076; admin operator routes retained as fallback); own-session mock-complete closes the G-301 class; own entitlement view with honest states; pricing CTAs → employer-login per D-079-supersession with enterprise Contact Sales pin retained; admin network overview — accounts, advisory-evidence claim queue, audit — per D-094/A-304 (shipped as v0.9.5, release 953) |
| 6 | Final review + ship | complete (v0.9.6) | 1-5 | — | Final review verdict SHIP — 2 P0s found + fixed (the /me claim-state leak over stale sessions, the dead billing confirm button), 3 P1s dispositioned; audit PASS — reconstruction clean, discipline green, 2 minor notes; ship gates green (1031 ai-service + 101 web tests, build clean); milestone tagged v0.9.6 (final v0.9.x patch = milestone release, release 954); release with full asset matrix + registry image via the tag lane (D-064); milestone merged to main |
---
## Phase List (v0.9 — complete)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | complete (v0.8.0) | — | — | Specify (D-074), clarify (D-075+), research (R-33+), plan, grill (G-301+), MVP/UX check complete; shipped as v0.8.0 |
| 1 | Billing foundation | complete (v0.8.1) | 0 | REQ-9-001 | `ai_service/billing/` module: PaymentProvider protocol + deterministic mock + SQLite store (7th D-027 family) + HMAC webhook verification + env-gated provider selection; tests never call a real provider |
| 2 | Catalog + pricing surface | complete (v0.8.2) | 1 | REQ-9-002 | Seeded catalog (posting packages + talent-access plans) via `/v1/billing/catalog`; pricing page runtime client fetch with honest loading/error states; tier copy data-driven |
| 3 | Checkout + webhook flow | complete (v0.8.3) | 2 | REQ-9-003 | `POST /v1/billing/admin/checkout` (admin-gated, D-076) + `POST /v1/billing/webhook` (HMAC-verified, idempotent event processing, at-least-once tolerance, local-row state resolution per G-302); entitlement granted on checkout.completed |
| 4 | Entitlement enforcement | complete (v0.8.4) | 3 | REQ-9-004 | Posting creation enforces package limits (honest 402 + upgrade CTA); expired/cancelled degrade to 402; admin surface shows per-employer entitlement state |
| 5 | Subscription lifecycle | complete (v0.8.5) | 4 | REQ-9-005 | Subscription state machine (trialing/active/past_due/cancelled/expired); idempotent renewals; manual admin grant/revoke with provenance; full billing event audit log; past_due grace window |
| 6 | Final review + ship | complete (v0.8.6) | 1-5 | — | Review clean; audit pass; milestone tagged v0.8.6 (final v0.8.x patch = milestone release); release with full asset matrix + registry image; milestone merged to main |
---
## v0.8 (Complete — Shipped as v0.7.6)
Ship Anywhere: installable on any Debian-based Linux server via docker (Gitea registry image, honest sandbox degrade per D-068), .deb packages (amd64+arm64, advisory postinst per D-067), and arch-aware binary (x64+arm64, R-24 arch-resolve) — with release consistency enforced by pre-commit hooks + Gitea Actions (3-runner fleet on CT 113 as committed fleet-as-code, machine-made releases with the 8-asset matrix + registry image on every release), the CLI carrying the full suite (guide + channel-aware doctor, D-066/D-072), and the evidence-backed admin runbook (G-210 parity-pinned). 7 phases (P0–P6), all 5 requirements (REQ-8-001..005) complete. Tags v0.7.0–v0.7.5 per phase, milestone release v0.7.6 (release 936). Final review CLEAN (G-201..G-210 honored — the smoke gate caught 4 real image bugs pre-ship, each fixed in-phase); audit PASS (reconstruction + discipline green).
## Phase List
### v0.8 (complete)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | complete (v0.7.0) | — | — | Specification, clarify (D-063..D-072), research (R-1..R-32), plan (rails→artifacts→onboarding→guides), grill (G-201..G-210), MVP/UX check complete (shipped as v0.7.0, release 913) |
| 1 | Release-consistency rails | complete (v0.7.1) | 0 | REQ-8-001 | 3 org-level runners green on CT 113 (hybrid labels, sibling mode); ci.yaml push lanes green; hello-world × 3 labels; release.yaml builds assets on tag (v0.7.1 release machine-made); pre-commit both-direction proof; secrets audit clean |
| 2 | Docker image channel | complete (v0.7.2) | 1 | REQ-8-002 | Multi-stage image (unprivileged, /health, state volume) pushed to the Gitea registry on every phase release; honest sandbox degrade (probe + notice + 503 remediation); in-container CLI semantics; smoke gate in workflow |
| 3 | Debian-native channel | complete (v0.7.3) | 2 | REQ-8-003 | arm64 QEMU-free cross-build; .deb x64+arm64 (advisory postinst); install.sh arch-resolve; full asset matrix (2 binaries + 2 checksums + 2 debs) on every release |
| 4 | CLI onboarding | complete (v0.7.4) | 3 | REQ-8-004 | Channel-aware doctor; nextcraft guide offline; honest in-container refusals; docker/deb E2Es env-gated green; guide↔runbook parity test |
| 5 | Admin guides | complete (v0.7.5) | 4 | REQ-8-005 | deploy/README.md full runbook (3 channels × install/upgrade, upgrade matrix, troubleshooting, evidence appendix); README quickstart; every recipe evidence-backed |
| 6 | Final review + ship | complete (v0.7.6) | 1-5 | — | Review clean; audit pass; milestone tagged v0.7.6 (final v0.7.x patch = milestone release, release 936); release with full asset matrix + registry image; milestone merged to main | Review clean; audit pass; milestone tagged v0.7.6 (final v0.7.x patch = milestone release); release with full asset matrix + image tags; milestone merged to main |
### v0.7 (complete)
| # | Name | Status | Depends On | Requirements | Success Criteria |
@@ -171,4 +239,4 @@ AI Tutor Architecture: Six AI tutor agents (Coach, Tutor, Lab, Assessor, Proctor
## v0.1 (Complete — Shipped as v0.1.0)
UI/UX Prototype: High-fidelity interactive prototype of all four Nextcraft surfaces. 7 phases (P0 + P1-P6 execution + P7 final). All 28 requirements complete. Tags v0.0.1–v0.0.7, milestone release v0.1.0.
UI/UX Prototype: High-fidelity interactive prototype of all four Nextcraft surfaces. 7 phases (P0 + P1-P6 execution + P7 final). All 28 requirements complete. Tags v0.0.1–v0.0.7, milestone release v0.1.0.
+6 -6
View File
@@ -1,5 +1,5 @@
{
"version": "0.7.0",
"version": "0.8.0",
"autonomy": {
"level": "full",
"decision_confidence_threshold": 0.6,
@@ -46,9 +46,9 @@
"projects": [],
"active_project": null,
"milestone": {
"version": "v0.7",
"name": "test-harness-review",
"type": "nfr",
"branch": "milestone/v0.7-test-harness-review"
"version": "v0.11",
"name": "placement-tracking",
"type": "feature",
"branch": "milestone/v0.11-placement-tracking"
}
}
}
+29
View File
@@ -0,0 +1,29 @@
# Build context hygiene (v0.8 P2): secrets and runtime droppings never
# enter an image layer. The .env* exclusion is the hard rule — the v1.8
# class of leak (tokens) must be structurally impossible here.
.git
.gitignore
.dockerignore
.env
.env.*
**/.env
**/.env.*
!.env.example
.ciagent
**/.ciagent
**/.venv
**/node_modules
**/__pycache__
**/*.pyc
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/.turbo
**/coverage
**/dist
apps/web/out
apps/web/.next
**/*.tsbuildinfo
.turbo
node_modules
*.log
+130
View File
@@ -0,0 +1,130 @@
# Push lanes — every branch, every push (REQ-8-001, D-063).
#
# Two jobs, two runner labels, NEVER wholesale `turbo lint`/`turbo typecheck`
# in the node lane (RESEARCH R-8): those turbo tasks pull in
# @nextcraft/ai-service#lint/typecheck, which hard-fail without the Python
# venv (apps/ai-service/scripts/lint.sh:8, typecheck.sh:13) — a venv that
# does not exist in the node:24-bookworm job container. The node lane runs
# lint/typecheck via `pnpm --filter` over the 5 TS workspaces only; the
# python lane invokes the three ai-service bash scripts directly (no pnpm
# needed in that container at all).
#
# Concurrency: serialize per-ref so a rapid-fire branch push reuses one lane
# instead of stacking jobs on the capacity-1 runners (R-4).
name: CI
on: push
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
jobs:
node:
# Fleet label `node` -> container docker.io/library/node:24-bookworm
# (deploy/ci/docker-compose.yaml, R-3 label map).
runs-on: node
steps:
# actions/checkout@v4 downloads from github.com (DEFAULT_ACTIONS_URL
# default). github.com egress is verified reachable from the LAN;
# the CT-local probe is still pending (Wave 1-1 blocked on founder
# deploy-key install). If checkout fails at the first real run, the
# fallback is raw git fetch steps using the automatic job token:
# - run: |
# git init -q .
# git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# git fetch --depth=1 origin "$GITHUB_SHA"
# git checkout FETCH_HEAD
- name: Checkout
uses: actions/checkout@v4
# Optional pnpm store cache (R-12). Depends on the runner cache server
# being reachable from job containers (R-5: cache.host = CT IP + a
# published port — configured in deploy/ci/). If the cache server
# ETIMEDOUTs on first real runs, drop this block (fallback: a
# volume-mounted pnpm store per R-12, or no cache at all — install is
# affordable on the LAN). continue-on-error keeps cache flakiness
# from ever turning the lane red; repeated timeouts = delete.
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
continue-on-error: true
# pnpm 12.3.4 is pinned by root package.json `packageManager`.
# node:24-bookworm still bundles corepack (24.x retains it, deprecated
# — Node >= 25 dropped it; R-11). Fallback if corepack is absent:
# npm i -g pnpm@12.3.4
- name: Enable pnpm
run: corepack enable pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Lint: per-workspace filters over the 5 TS workspaces ONLY — never
# `turbo lint` (see header note).
- name: Lint (TS workspaces)
run: >-
pnpm --filter web --filter @nextcraft/cli --filter @nextcraft/ui
--filter @nextcraft/types --filter @nextcraft/mock-data lint
# Typecheck: same filter set, per-workspace tsc --noEmit.
- name: Typecheck (TS workspaces)
run: >-
pnpm --filter web --filter @nextcraft/cli --filter @nextcraft/ui
--filter @nextcraft/types --filter @nextcraft/mock-data typecheck
- name: Web tests (node:test via tsx)
run: pnpm web:test
# CLI default test script (tsx --test tests/*.test.ts) excludes the
# env-gated E2E (apps/cli/package.json `test:e2e`, CLI_E2E=1) — the
# fresh-clone E2E stays out of job containers per D-063.
- name: CLI tests (node:test via tsx, E2E env-gated out)
run: pnpm cli:test
python:
# Fleet label `python` -> container docker.io/library/python:3.11-bookworm
# (R-3). The three scripts below are self-contained venv-relative bash
# (R-9) — no pnpm, no node, no turbo in this lane.
#
# P1 CI finding: actions/checkout@v4 is a NODE-based action — it cannot
# exec in the python:3.11-bookworm container (exit 127: "node" not in
# $PATH). This lane checks out via raw git steps with the automatic job
# token instead (the R-8 fallback path, promoted to primary here).
runs-on: python
steps:
- name: Checkout (raw git — node actions cannot run in this container)
run: |
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
git fetch --depth=1 origin "$GITHUB_SHA"
git checkout FETCH_HEAD
# NOTE: no actions/cache here — it is a node-based action and this
# container has no node (exit 127; same class as the checkout finding).
# The venv rebuild is ~25s warm — acceptable without cache; pip's own
# HTTP cache is cold but the LAN is fast. Revisit only if the lane
# slows materially (volume-mounted store per R-12, or a pip download
# cache directory via container options).
# bootstrap.sh is idempotent: creates .venv (with the --without-pip +
# get-pip fallback for Debian-without-ensurepip) and pip-installs
# the project with [dev] extras (ruff, mypy, pytest).
- name: Bootstrap ai-service venv
run: bash apps/ai-service/scripts/bootstrap.sh
- name: Lint (ruff)
run: bash apps/ai-service/scripts/lint.sh
- name: Typecheck (mypy, D-058 baseline)
run: bash apps/ai-service/scripts/typecheck.sh
# Fast pytest lane per D-063: -m "not real_server and not userns"
# (scripts/test.sh FAST=1). real_server/userns tiers stay local-only
# — job containers genuinely cannot run them (docker default seccomp
# denies the nested `unshare --user` the backend needs; R-8).
- name: Tests (fast lane)
run: FAST=1 bash apps/ai-service/scripts/test.sh
+28
View File
@@ -0,0 +1,28 @@
# Fleet proof (REQ-8-001, MH-1c): workflow_dispatch one-step echo job per
# fleet label. Dispatching this workflow proves all three runners
# register, pull their job image, and execute a job. Observed green in the
# Actions tab = the runner fleet is verified end-to-end.
#
# Labels (R-3 fleet map, deploy/ci/docker-compose.yaml):
# node -> node:24-bookworm (nc-runner-node)
# ubuntu-latest -> runner-images:ubuntu-latest (nc-runner-node, hybrid)
# python -> python:3.11-bookworm (nc-runner-python)
name: Hello fleet
on: workflow_dispatch
jobs:
node:
runs-on: node
steps:
- run: echo "hello from node"
python:
runs-on: python
steps:
- run: echo "hello from python"
ubuntu-latest:
runs-on: ubuntu-latest
steps:
- run: echo "hello from ubuntu-latest"
+377
View File
@@ -0,0 +1,377 @@
# Release lane — tag push only (REQ-8-001, D-063, R-14/R-15).
#
# Fires on `v*` tag pushes. Builds the x64 SEA binary stamped with the tag,
# generates the sha256 sidecar, and attaches both to the tag's Gitea
# release via the REST API (the same mechanics as scripts/release-assets.sh
# — that script stays untouched as the MANUAL fallback with its
# .env-only token resolution, per D-064/R-14).
#
# Within-phase proof (G-203): at verify time push a temporary
# `v0.7.1-rc1`-style tag to the phase-branch head, observe this workflow
# build + attach green (release immediately deleted or pre-release), then
# delete the tag. The phase ship tag remains the real artifact.
#
# Token precedence (R-13): the automatic job token (auto-provided as the
# GITHUB_TOKEN env / `${{ github.token }}` / `${{ gitea.token }}`) HAS
# releases scope and can create releases + attach assets, but it CANNOT
# publish packages (upstream-confirmed). The repo secret GITEA_TOKEN (a
# founder-set PAT), when present, WINS (lowest level wins). This workflow
# uses `${{ secrets.GITEA_TOKEN }}` when set, else the automatic token.
#
# Extension points (P2/P3, G-202: this workflow is the shared rail):
# [P2] after "Build binary": docker build + login + push to
# git.coreci.dev/coreci/nextcraft:$TAG + :latest with
# secrets.REGISTRY_TOKEN, plus the five-assertion smoke gate (G-204).
# [P3] arm64 cross-build (G-201 ladder) + both .deb packages (R-22/R-23)
# join the attach step below — full asset matrix per D-064.
name: Release
on:
push:
tags: ['v*']
jobs:
release:
# Fleet label `release` -> container node:24-bookworm (R-3: node +
# dpkg-deb + git + curl; the deb toolchain ships with bookworm).
runs-on: release
env:
# Token precedence per R-13: repo secret GITEA_TOKEN (PAT) wins when
# present; else the automatic job token (releases scope is enough for
# release-id resolution + asset upload — R-14). Never echo this.
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
steps:
- name: Checkout tag
uses: actions/checkout@v4
# P2: the release lane builds/pushes the docker image. The job
# container (node:24-bookworm) has no docker CLI and no daemon — the
# host daemon is bind-mounted into this job via the runner's
# container.options (deploy/ci/config-runner-release.yaml, sibling
# mode); only the CLI is missing. Bookworm main ships docker.io
# (engine+cli — the engine stays dormant; the socket is the host's).
- name: Install docker CLI (host daemon is socket-mounted)
run: |
apt-get update -qq && apt-get install -y -qq docker.io >/dev/null
docker version --format 'client {{.Client.Version}} / server {{.Server.Version}}'
- name: Enable pnpm
run: corepack enable pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# build:binary stamps NEXTCRAFT_VERSION_STAMP from the tag argument
# (build-binary.mjs validates it; `--version` reports it — G-102).
- name: Build x64 binary (SEA)
run: pnpm --filter @nextcraft/cli build:binary ${{ gitea.ref_name }} --arch x64
# P3 (REQ-8-003, R-25/G-201): arm64 is QEMU-free — the ONLY
# arch-specific step (the node binary copy) uses the official arm64
# node tarball; esbuild + postject are file surgery, nothing arm64
# executes on the x64 runner. The G-201 fallback ladder lives in
# PLAN MH-3a: if the probe ever fails, binfmt-full then x64-only
# deferral — never a red lane.
- name: Build arm64 binary (SEA, QEMU-free cross-build)
run: pnpm --filter @nextcraft/cli build:binary ${{ gitea.ref_name }} --arch arm64
# P3 (R-22/R-23, D-067): deb packages for both arches — binary +
# docs, advisory postinst, Version = tag minus v.
- name: Build debs (x64 + arm64)
run: |
bash scripts/build-deb.sh ${{ gitea.ref_name }} x64
bash scripts/build-deb.sh ${{ gitea.ref_name }} arm64
# Deb smoke (MH-3b): the amd64 deb installs in a fresh debian
# container and its binary speaks (the arm64 deb cannot exec on this
# x64 runner — its checksum + structure are the release-side proof).
# The deb enters via STDIN tar — sibling-mode bind mounts resolve on
# the HOST, and the job workspace path does not exist there (R-7's
# documented gotcha; docker build works because it TARs the context).
- name: Deb smoke — fresh-container install + advisory doctor
run: |
set -euo pipefail
TAG="${{ gitea.ref_name }}"
VER="${TAG#v}"
DEB="apps/cli/dist/deb/nextcraft_${VER}_amd64.deb"
[ -f "$DEB" ] || { echo "missing $DEB"; exit 1; }
tar -cf - -C "$(dirname "$DEB")" "$(basename "$DEB")" \
| docker run --rm -i --rm debian:bookworm bash -c '
set -e
mkdir -p /deb && tar -xf - -C /deb
DEB_FILE="/deb/$(basename /deb/nextcraft_*.deb)"
dpkg -i "$DEB_FILE" >/tmp/dpkg.log 2>&1 || true # advisory postinst: exit 0
/usr/bin/nextcraft --version
dpkg-deb --field "$DEB_FILE" Package Version Architecture
'
- name: Verify artifacts
run: |
set -euo pipefail
for A in x64 arm64; do
BIN="apps/cli/dist/nextcraft-linux-$A"
SUM="$BIN.sha256"
[ -f "$BIN" ] || { echo "missing $BIN"; exit 1; }
[ -f "$SUM" ] || { echo "missing $SUM"; exit 1; }
# The sidecar names the bare file — verify from the dist dir.
(cd "$(dirname "$BIN")" && sha256sum -c "$(basename "$SUM")")
echo "$A size: $(stat -c%s "$BIN") bytes"
done
for D in apps/cli/dist/deb/*.deb; do
[ -f "$D.sha256" ] || { echo "missing $D.sha256"; exit 1; }
(cd "$(dirname "$D")" && sha256sum -c "$(basename "$D.sha256")")
done
# Resolve the release id for the tag (the release is auto-created by
# Gitea on tag push if the repo setting is on; if not, create it
# first). Same call as scripts/release-assets.sh:32-49 (R-14).
- name: Resolve release id
run: |
set -euo pipefail
TAG="${{ gitea.ref_name }}"
BASE="https://git.coreci.dev/api/v1/repos/coreci/nextcraft"
RESPONSE="$(curl -sS -w '\n%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
"$BASE/releases/tags/$TAG")"
BODY="$(echo "$RESPONSE" | sed '$d')"
CODE="$(echo "$RESPONSE" | tail -1)"
if [ "$CODE" = "404" ]; then
echo "release for $TAG not found — creating it"
BODY="$(curl -sS -f -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\"}" \
"$BASE/releases")"
else
echo "$CODE" | grep -q '^2' || { echo "release lookup failed: $CODE"; echo "$BODY"; exit 1; }
fi
RELEASE_ID="$(echo "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
echo "release id: $RELEASE_ID"
echo "RELEASE_ID=$RELEASE_ID" >> "$GITHUB_ENV"
# Attach the FULL asset matrix (D-064): 2 binaries + 2 checksums +
# 2 debs + 2 deb checksums. actions/upload-artifact is a DIFFERENT
# mechanism (job artifacts via the cache server, not release
# downloads) — do NOT use it here (R-14). curl multipart is the API.
- name: Attach asset matrix to release
run: |
set -euo pipefail
BASE="https://git.coreci.dev/api/v1/repos/coreci/nextcraft"
upload() {
local file="$1" name="$2"
curl -sS -f -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@$file" \
"$BASE/releases/$RELEASE_ID/assets?name=$name" >/dev/null
echo "attached $name"
}
for A in x64 arm64; do
upload "apps/cli/dist/nextcraft-linux-$A" "nextcraft-linux-$A"
upload "apps/cli/dist/nextcraft-linux-$A.sha256" "nextcraft-linux-$A.sha256"
done
TAG="${{ gitea.ref_name }}"
VER="${TAG#v}"
for D in amd64 arm64; do
upload "apps/cli/dist/deb/nextcraft_${VER}_${D}.deb" "nextcraft_${VER}_${D}.deb"
upload "apps/cli/dist/deb/nextcraft_${VER}_${D}.deb.sha256" "nextcraft_${VER}_${D}.deb.sha256"
done
# -- P2 extension (REQ-8-002, R-20, D-065): build + push the docker
# image to the Gitea registry on every phase release ----------------
# The job token cannot publish packages (R-13, upstream-confirmed);
# REGISTRY_TOKEN (repo Actions secret, self-served at P1: G-205) is
# the PAT with package-write.
- name: Build docker image
run: |
set -euo pipefail
TAG="${{ gitea.ref_name }}"
docker build \
--build-arg NEXTCRAFT_VERSION="$TAG" \
-t "git.coreci.dev/coreci/nextcraft:$TAG" \
-t "git.coreci.dev/coreci/nextcraft:latest" \
.
- name: Push image to the Gitea registry
run: |
set -euo pipefail
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.coreci.dev \
-u coreci --password-stdin
TAG="${{ gitea.ref_name }}"
docker push "git.coreci.dev/coreci/nextcraft:$TAG"
docker push "git.coreci.dev/coreci/nextcraft:latest"
# Smoke gate (G-204, five pinned assertions): a red image = red
# release. Probes run via docker exec INSIDE the container — the
# job container's 127.0.0.1 is not the host, and the smoke container
# publishes no ports (the admin-facing port map is the admin's call,
# not the gate's).
- name: Smoke gate — unprivileged run proves the honest degrade
run: |
set -euo pipefail
TAG="${{ gitea.ref_name }}"
CID=""
cleanup() { [ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true; }
trap cleanup EXIT
CID="$(docker run -d "git.coreci.dev/coreci/nextcraft:$TAG")"
echo "container $CID up"
# 1. /health 200 within the window (in-container urllib — slim
# has no curl; the HEALTHCHECK does the same)
ok=""
for i in $(seq 1 30); do
if docker exec "$CID" python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8420/health', timeout=3).status == 200 else 1)" 2>/dev/null; then ok=1; break; fi
sleep 2
done
[ -n "$ok" ] || { echo "FAIL: /health never returned 200"; docker logs "$CID" 2>&1 | tail -30; exit 1; }
echo "assert 1/5: /health 200"
# 2. GET / is HTML with a real document marker (not just non-JSON
# — the v0.4.6 lesson; G-204). One-line python: checks the
# doctype/html prefix on the FULL lowered head, not a 6-char
# slice (rc6 finding — the slice truncated the marker).
docker exec "$CID" python3 -c "import urllib.request,sys; body = urllib.request.urlopen('http://127.0.0.1:8420/', timeout=5).read(); head = body.lstrip()[:32].lower(); sys.exit(0 if (head.startswith(b'<!doctype') or head.startswith(b'<html')) else 1)" \
|| { echo "FAIL: / is not HTML"; exit 1; }
echo "assert 2/5: / serves HTML"
# 3. degrade boot notice present in container logs (unprivileged
# default → seccomp blocks userns → loud notice, D-068).
# Retry window: the /health probe can pass in the same instant
# the boot log is still flushing (rc4 finding — 60ms race);
# the notice check polls up to 15s, it does not trust timing.
ok=""
for i in $(seq 1 15); do
if docker logs "$CID" 2>&1 | grep -q "SANDBOX FABRIC DEGRADED"; then ok=1; break; fi
sleep 1
done
[ -n "$ok" ] \
|| { echo "FAIL: degrade boot notice absent"; docker logs "$CID" 2>&1 | tail -30; exit 1; }
echo "assert 3/5: degrade notice logged"
# 4. fabric env-create is refused BEFORE the fabric (503 degraded
# or 403 gate-first — both prove the degrade path short-circuits
# learner env creation; a 201 would prove the fabric ran, which
# an unprivileged container cannot allow). The probe python is
# written to a file first (a yaml block scalar cannot embed
# unindented command substitution continuations).
cat > /tmp/smoke_probe.py << 'PYEOF'
import urllib.request, urllib.error, json, sys
req = urllib.request.Request('http://127.0.0.1:8420/v1/sandboxes',
data=json.dumps({'learner_id':'smoke','task_id':'t'}).encode(),
headers={'Content-Type':'application/json'}, method='POST')
try:
print(urllib.request.urlopen(req, timeout=5).status)
except urllib.error.HTTPError as e:
print(e.code)
except Exception:
print('ERR')
sys.stdout.flush()
PYEOF
docker cp /tmp/smoke_probe.py "$CID":/tmp/smoke_probe.py
STATUS="$(docker exec "$CID" python3 /tmp/smoke_probe.py)"
[ "$STATUS" = "503" ] || [ "$STATUS" = "403" ] \
|| { echo "FAIL: env-create was $STATUS (expected 503 degraded or 403 gate)"; exit 1; }
echo "assert 4/5: degraded env-create refused ($STATUS)"
# 5. in-container CLI speaks (embedded doctor reports the docker
# channel — D-066) + clean stop
docker exec "$CID" nextcraft --version \
|| { echo "FAIL: embedded CLI does not run"; exit 1; }
echo "assert 5/5: embedded CLI alive + clean stop"
docker rm -f "$CID" >/dev/null && CID=""
# -- R1 deploy leg (REQ-v1-003 machinery set c18619; hep APPROVE c18623;
# vehicle call c18634: the last mile is THIS pipeline, not an SRE infra
# detour). Pipeline-only host refresh of the product host - no manual
# host pokes. Provenance = this job's run id + the post-deploy identity
# read below. The footer is retired as a freshness marker (it is the
# code's own label - c18624 correction, confirmed by hep): freshness
# reads are machine-read (/health + / serving HTML + the /v1/placements/
# admin route registration in /openapi.json).
#
# One-time plumbing preconditions (declared for the MR review; NOT a
# routing of the refresh to SRE - SRE keeps the R3 re-probe lane only):
# * DEPLOY_SSH_KEY - repo Actions secret: the least-scope deploy key
# authorized on the product host's deploy account (deploy@10.0.0.105
# per deploy/README.md topology). One-time grant via the SRE/console
# precedent (deploy/ci README G-206 pattern; self-serve secret PUT
# precedent: REGISTRY_TOKEN, HTTP 201 on 2026-09-27).
# * (optional hardening) DEPLOY_HOSTKEY - repo Actions secret holding
# the product host's complete ssh public host-key line; when set it
# is PINNED (StrictHostKeyChecking=yes), else accept-new.
# A missing DEPLOY_SSH_KEY at run time fails this leg LOUDLY - a red
# Actions tab on a tag is a red deploy by design (deploy/README.md);
# it is never silently skipped (set R1: the gate stays clearable on
# evidence). The release/asset path is untouched: a deploy-leg
# precondition failure cannot damage artifact shipping.
deploy:
needs: release
runs-on: release
steps:
- name: Preflight - fail loud when the leg is unconfigured
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
echo "deploy preflight: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / sha $GITHUB_SHA"
[ -n "$DEPLOY_SSH_KEY" ] || { echo "FATAL: DEPLOY_SSH_KEY unset - deploy leg unconfigured; host NOT refreshed this release; release artifacts stand (release job already green)"; exit 1; }
- name: Refresh the product host over the declared ssh channel
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOSTKEY: ${{ secrets.DEPLOY_HOSTKEY }}
run: |
set -euo pipefail
trap 'rm -f /tmp/dk /tmp/kh' EXIT
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null
install -m 600 /dev/null /tmp/dk
printf '%s\n' "$DEPLOY_SSH_KEY" > /tmp/dk
unset DEPLOY_SSH_KEY
if [ -n "${DEPLOY_HOSTKEY:-}" ]; then
printf '%s\n' "$DEPLOY_HOSTKEY" > /tmp/kh
SSH_PIN="-oUserKnownHostsFile=/tmp/kh -oStrictHostKeyChecking=yes"
else
SSH_PIN="-oStrictHostKeyChecking=accept-new"
fi
TAG="${{ gitea.ref_name }}"
ssh -i /tmp/dk -o BatchMode=yes $SSH_PIN deploy@10.0.0.105 bash -s "$TAG" << 'REMOTE'
set -euo pipefail
TAG="$1"
if command -v nextcraft >/dev/null 2>&1; then
echo "channel: binary/deb - the one-command upgrade (idempotent, self-verifying per deploy/README.md)"
nextcraft deploy --service --ref "$TAG"
elif [ -n "$(docker ps --format '{{.Names}}' | grep -x nextcraft || true)" ]; then
echo "channel: docker - README upgrade (pull + recreate; nextcraft-state persists)"
docker pull git.coreci.dev/coreci/nextcraft:"$TAG"
docker rm -f nextcraft
docker run -d --name nextcraft -p 8420:8420 -v nextcraft-state:/home/nextcraft/.nextcraft git.coreci.dev/coreci/nextcraft:"$TAG"
else
echo "FATAL: no nextcraft channel on $(hostname) (no nextcraft on PATH; no docker container named nextcraft) - failing loud"
exit 1
fi
echo "--- channel verify (advisory; the identity read below is canonical)"
command -v nextcraft >/dev/null 2>&1 && nextcraft --version || true
REMOTE
RC=$?
echo "host refresh done (rc=$RC); provenance: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / sha $GITHUB_SHA"
exit $RC
- name: Post-deploy identity read - the R1 provenance surface
run: |
set -euo pipefail
TAG="${{ gitea.ref_name }}"
echo "waiting for the stack to settle..."
OK=""
for i in $(seq 1 12); do
CODE_H="$(curl -sS -o /tmp/h -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/health || true)"
if [ "$CODE_H" = "200" ] && grep -q '"status"' /tmp/h; then OK=1; break; fi
sleep 5
done
[ -n "$OK" ] || { echo "FAIL: /health never returned 200 within the window"; exit 1; }
echo "assert 1/3: /health 200 - $(head -c 200 /tmp/h)"
CODE_R="$(curl -sS -o /tmp/r -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/)"
head -c 64 /tmp/r | tr -d '[:space:]' | head -c 9 | grep -qiE '^<!doctype|^<html' || { echo "FAIL: / is not HTML (code $CODE_R)"; exit 1; }
echo "assert 2/3: / serves HTML ($CODE_R)"
CODE_O="$(curl -sS -o /tmp/o -m 25 -w '%{http_code}' https://nextcraft.coreci.dev/openapi.json)"
grep -q '"/v1/placements/admin' /tmp/o || { echo "FAIL: counting surface NOT registered on the served build (/openapi.json has no /v1/placements/admin routes) - honest red; R2 root-cause owns the diagnosis per c18619"; exit 1; }
echo "assert 3/3: /v1/placements/admin registered in /openapi.json"
echo "(advisory served version: $(grep -o '"version":"[^"]*"' /tmp/o | head -1))"
echo "PROVENANCE (R1): deploy run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / head $GITHUB_SHA - identity reads green"
+1 -1
View File
@@ -1,3 +1,3 @@
shamefully-hoist=true
strict-peer-dependencies=false
pnpm-approved-builds=esbuild,core-js-pure
pnpm-approved-builds=esbuild,core-js-pure
+84
View File
@@ -0,0 +1,84 @@
# Pre-commit hooks (D-070) — local hygiene mirroring the CI lanes.
#
# Design contract (G-209): every hook is LOCAL-ONLY (no network at hook
# time — the mirror-style hooks below are fetched once at `pre-commit
# install`/`pre-commit run` time from github.com, which this LAN reaches;
# the language:system hooks run zero network). Hooks FAIL LOUD when the
# toolchain is missing (D-070: a contributor without the toolbox gets an
# explicit pointer, not a silent skip) — the no-toolbox escape is
# `git commit --no-verify` (or `SKIP=...`), documented in README.md with
# the loud note that CI still gates every push.
#
# Scope discipline mirrors .gitea/workflows/ci.yaml: ruff only touches
# ai-service python; eslint/tsc only touch the TS workspaces; hygiene is
# repo-wide.
repos:
# ---- basic hygiene (repo-wide, zero toolchain required)
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-added-large-files
# ---- ruff (ai-service python, D-063 lane parity)
# Runs the venv-relative binary exactly like apps/ai-service/scripts/lint.sh
# (venv is the repo's authority for the python toolchain). FAILS LOUD with
# a bootstrap hint if the venv is missing — never a silent skip.
- repo: local
hooks:
- id: ruff
name: ruff check (ai-service)
entry: apps/ai-service/scripts/lint.sh
language: system
types: [python]
files: ^apps/ai-service/
pass_filenames: false
# ---- eslint (TS workspaces, ONE root flat config at eslint.config.mjs)
# Each workspace's `lint` script is `eslint .` resolving that config
# upward, so a single root invocation is exact parity with the node
# lane's per-workspace runs. `pnpm` resolves via PATH or corepack
# (same discovery as nextcraft doctor); FAILS LOUD if neither exists.
- id: eslint
name: eslint (TS workspaces)
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM exec eslint apps/web apps/cli packages/ui packages/types packages/mock-data'
language: system
files: ^(apps/web|apps/cli|packages/ui|packages/types|packages/mock-data)/.*\.(ts|tsx|mjs|cjs)$
pass_filenames: false
# ---- tsc --noEmit per TS workspace (node-lane parity: pnpm --filter
# over the same five workspaces; the tsconfig graph stays per-project)
# (the shared PCMD discovery is repeated per hook because pre-commit
# hooks run isolated entries)
- id: tsc-web
name: tsc --noEmit (web)
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter web typecheck'
language: system
files: ^apps/web/.*\.tsx?$
pass_filenames: false
- id: tsc-cli
name: tsc --noEmit (cli)
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter @nextcraft/cli typecheck'
language: system
files: ^apps/cli/.*\.tsx?$
pass_filenames: false
- id: tsc-ui
name: tsc --noEmit (ui)
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter @nextcraft/ui typecheck'
language: system
files: ^packages/ui/.*\.tsx?$
pass_filenames: false
- id: tsc-types
name: tsc --noEmit (types)
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter @nextcraft/types typecheck'
language: system
files: ^packages/types/.*\.tsx?$
pass_filenames: false
- id: tsc-mock-data
name: tsc --noEmit (mock-data)
entry: bash -c 'PNPM="$(command -v pnpm || echo "corepack pnpm")"; $PNPM --filter @nextcraft/mock-data typecheck'
language: system
files: ^packages/mock-data/.*\.tsx?$
pass_filenames: false
+111
View File
@@ -0,0 +1,111 @@
# Nextcraft — single-port docker image (v0.8 P2, REQ-8-002, R-16..R-19)
#
# Topology: uvicorn :8420 serves BOTH the API (/v1/*, /health) and the
# exported web UI (everything else) — the same single-port production
# shape as `nextcraft deploy` (D-039). Deps are pre-loaded in image
# layers; runtime is unprivileged by default (honest sandbox degrade:
# ai_service/sandbox/capability.py probes + api/sandboxes.py 503s with
# remediation — D-068/G-208).
#
# Build context is the repo root. Build args:
# NEXTCRAFT_VERSION — stamped into the embedded CLI (`--version`)
#
# Stages:
# 1. web-export — node:24-bookworm: pnpm workspace install, static
# export (NEXT_PUBLIC_AI_SERVICE_URL=self), SEA CLI binary build
# 2. venv — python:3.11-bookworm: production deps only (no [dev])
# 3. runtime — python:3.11-slim-bookworm: non-root, venv + package +
# export + CLI, /health healthcheck, state volume
# -- stage 1: web export + CLI binary ---------------------------------------
FROM node:24-bookworm AS web-export
ARG NEXTCRAFT_VERSION=0.0.0-dev
WORKDIR /repo
# Toolchain: corepack ships with node 24 (deprecated but present).
RUN corepack enable pnpm
# Workspace manifests first (layer-cacheable install).
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc tsconfig.json ./
COPY apps/web/package.json apps/web/
COPY apps/cli/package.json apps/cli/
COPY packages/types/package.json packages/types/
COPY packages/ui/package.json packages/ui/
COPY packages/mock-data/package.json packages/mock-data/
RUN pnpm install --frozen-lockfile
# Sources (web build needs all workspaces; CLI build needs its tree).
COPY apps ./apps
COPY packages ./packages
# Static export (same command as the deploy path): the export lands in
# apps/web/out and is served same-origin by uvicorn.
ENV NEXT_PUBLIC_AI_SERVICE_URL=self
RUN pnpm --filter web build
# Embedded CLI (D-066): doctor/verify/guide work in-container; the daemon
# commands refuse honestly (see apps/cli/src — NC_CONTAINER detection).
RUN pnpm --filter @nextcraft/cli build:binary "$NEXTCRAFT_VERSION"
# -- stage 2: python venv ----------------------------------------------------
FROM python:3.11-bookworm AS venv
WORKDIR /repo
# The full source tree installs the package (bootstrap.sh's mechanism
# without [dev]: production deps only — the image runs the app, it never
# lints/tests it; the runner lanes own that).
COPY apps/ai-service /repo/apps/ai-service
RUN python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir --upgrade pip && \
/opt/venv/bin/pip install --no-cache-dir /repo/apps/ai-service
# -- stage 3: runtime --------------------------------------------------------
FROM python:3.11-slim-bookworm AS runtime
WORKDIR /opt/nextcraft
# Non-root user; home carries the state volume (~/.nextcraft semantics —
# config.py anchors db/sandboxes under $HOME/.nextcraft, R-17).
RUN useradd --create-home --uid 10001 nextcraft
# venv from stage 2 (production deps only).
COPY --from=venv /opt/venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
# The installed ai_service package is IN the venv's site-packages; the
# source tree rides along for transparency (PYTHONPATH prefers the
# installed package).
COPY --from=venv /repo/apps/ai-service /opt/nextcraft/ai-service-src
RUN rm -rf /opt/nextcraft/ai-service-src/.venv
# Web export from stage 1.
COPY --from=web-export /repo/apps/web/out /opt/nextcraft/web-out
ENV AI_WEB_STATIC_DIR=/opt/nextcraft/web-out
# Image marker: NC_CONTAINER=1 (context for the CLI container-mode
# branches and the degrade boot notice; the capability PROBE — not this
# marker — decides degrade, D-068).
ENV NC_CONTAINER=1
# Embedded CLI (D-066).
COPY --from=web-export /repo/apps/cli/dist/nextcraft-linux-x64 /usr/local/bin/nextcraft
RUN chmod 0755 /usr/local/bin/nextcraft
# App dir + state: db/sandboxes under /home/nextcraft/.nextcraft (R-17).
# The state root is pre-created with correct ownership BEFORE the VOLUME
# declaration — an anonymous volume copies the image's directory CONTENTS
# and ownership at first use, so the app (uid 10001) can mkdir its data/
# and sandboxes/ subdirs on a clean boot (rc5 smoke-gate finding: the
# bare useradd home did not carry a writable .nextcraft).
RUN mkdir -p /home/nextcraft/.nextcraft/data \
&& chown -R nextcraft:nextcraft /opt/nextcraft /home/nextcraft/.nextcraft
WORKDIR /opt/nextcraft/ai-service-src
USER nextcraft
ENV AI_HOST=0.0.0.0 AI_PORT=8420
EXPOSE 8420
# slim has no curl — the healthcheck speaks python (stdlib urllib).
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8420/health', timeout=4).status == 200 else 1)"]
VOLUME ["/home/nextcraft/.nextcraft"]
CMD ["python3", "-m", "uvicorn", "ai_service.main:app", "--host", "0.0.0.0", "--port", "8420"]
+42 -5
View File
@@ -1,16 +1,26 @@
# Nextcraft
AI-native outcome school + marketplace — graduates prove what they can build, not what they can write.
**TL;DR** — AI-native outcome school + marketplace — graduates prove what they can build, not what they can write. This file is the front door: three install channels, dev setup from a clone, and the CLI reference; the admin runbook for standing the stack up on a server lives in [deploy/README.md](deploy/README.md).
## Quickstart
One-liner install (linux x64):
nextcraft installs on any Debian-based Linux server through **three channels** (full recipes in [deploy/README.md](deploy/README.md) or `nextcraft guide`):
```sh
# 1. docker — the whole stack pre-loaded (API + UI + CLI in one image):
docker run -d --name nextcraft -p 8420:8420 \
-v nextcraft-state:/home/nextcraft/.nextcraft \
git.coreci.dev/coreci/nextcraft:<release-tag>
# 2. deb — apt-shaped (amd64 + arm64):
dpkg -i nextcraft_<ver>_<arch>.deb && nextcraft deploy --service
# 3. binary — the one-liner (linux x64 + arm64):
curl -fsSL https://git.coreci.dev/coreci/nextcraft/raw/main/scripts/install.sh | sh
nextcraft deploy --service
```
That downloads the latest release's `nextcraft` CLI binary, verifies its sha256 checksum, and installs it to `~/.local/bin` (PATH hint printed if needed). Every release ships fresh binaries — re-run the one-liner to upgrade.
The binary download verifies sha256 + version stamps before install (mismatch = hard stop). Every release ships fresh binaries, debs, and a registry image — all machine-made by the [release workflow](.gitea/workflows/release.yaml).
### Server (deploy the whole stack, one command)
@@ -31,6 +41,31 @@ nextcraft dev # run the ai-service dev server on :8420 (web dev server:
The E2E test (`apps/cli/tests/fresh-clone-e2e.test.ts`) proves this exact sequence on a fresh clone.
#### Pre-commit hooks (optional, recommended)
The repo ships a `.pre-commit-config.yaml` that mirrors the CI lanes locally: ruff over the ai-service python, eslint over the five TS workspaces (one root flat config), `tsc --noEmit` per workspace, plus whitespace/merge-conflict/large-file hygiene. Hooks are local-only and fail loud.
```sh
pip install pre-commit # or: pipx install pre-commit
pre-commit install
```
From then on every `git commit` runs the hooks on the staged changes. First run may fetch the hygiene hook repo (github.com, reachable from this LAN).
**No toolbox? The honest skip path:** if you don't have the ruff/eslint/tsc toolchain set up (e.g. no ai-service venv yet — run `nextcraft bootstrap` first), hooks will fail loud with a pointer. The simplest honest escape for a contributor without the toolbox is:
```sh
git commit --no-verify
```
or skip specific hooks for one commit:
```sh
SKIP=ruff,eslint,tsc-web,tsc-cli,tsc-ui,tsc-types,tsc-mock-data git commit
```
**Loud note: CI still gates.** Skipping locally changes nothing about the push lanes — `.gitea/workflows/ci.yaml` runs lint + typecheck + tests on every push to every branch. A `--no-verify` commit that breaks lint gets caught at push time, just later and more publicly.
### No binary / non-linux?
The installer degrades to printed source instructions. Manual equivalent:
@@ -86,8 +121,10 @@ Durable state (SQLite DB, sandbox workdirs, daemon pid/log) lives in `~/.nextcra
## Status
**Milestone v0.6** — Marketplace Engine (shipped as v0.5.6): real job-postings backend behind the marketplace surfaces (6th SQLite store family + seeded ingestion adapter + admin authoring surface), AI tagging with facet-driven filters, embedding-backed semantic search with computed match scores, employer enrichment with per-field provenance, resume-to-profile auto-fill with an explicit confirm gate, and SEO programmatic pages (metadata, JSON-LD, dynamic sitemap/robots).
**Milestone v0.8** — Ship Anywhere (in progress): installable on any Debian-based server via every mainstream channel — docker image (Gitea registry, honest sandbox degrade), .deb packages (amd64+arm64, advisory postinst), arch-aware one-liner binary — with release consistency enforced by pre-commit hooks + Gitea Actions pipelines (runner fleet on CT 113, machine-made releases), the CLI carrying the full suite (`nextcraft guide`, channel-aware doctor), and the admin runbook (deploy/README.md).
Prior: v0.7 Test Harness Review (shipped as v0.6.6) · **Milestone v0.6** — Marketplace Engine (shipped as v0.5.6): real job-postings backend behind the marketplace surfaces (6th SQLite store family + seeded ingestion adapter + admin authoring surface), AI tagging with facet-driven filters, embedding-backed semantic search with computed match scores, employer enrichment with per-field provenance, resume-to-profile auto-fill with an explicit confirm gate, and SEO programmatic pages (metadata, JSON-LD, dynamic sitemap/robots).
Prior: v0.5 Real Server Voice, KYC/Identity, Sandbox Environments, Seq-Lease (shipped as v0.4.5) · v0.3 Credential Engines (v0.2.8) · v0.2 AI Tutor Architecture (v0.2.0) · v0.1 UI/UX Prototype (v0.1.0)
Initialized via CIAgent v0.7.0
Initialized via CIAgent v0.7.0
+28 -1
View File
@@ -83,9 +83,21 @@ AI_VOICE_PROVIDER=mock
# AI_ADMIN_TOKEN=
# P5 (D-056) placeholder: the deployed origin for sitemap.xml / robots.txt
# absolute URLs (e.g. https://nextcraft.example). Unset → SEO phase
# serves the empty static-page set with a boot warning (G-105).
# serves the empty-page set with a boot warning (G-105).
# AI_PUBLIC_BASE_URL=
# --- Employer auth throttles (REQ-10-001, D-088) ---
# Documentation of the ACTIVE defaults (a-313) — both knobs are always
# on as Settings ints; these env entries override for ops tuning.
# (a) Failed logins allowed per email per rolling 15-min window
# (default 5 → 429 with an honest "wait" detail; a successful
# login clears the window).
# AI_EMPLOYER_LOGIN_FAILURES_PER_WINDOW=5
# (b) Well-formed login+register attempts per rolling 60-s window
# across all emails (default 30 → 429; failed passwords count —
# the credential-stuffing bound, G-314).
# AI_EMPLOYER_AUTH_ATTEMPTS_PER_MIN=30
# --- Semantic search embeddings (REQ-6-003, D-051) ---
# 'mock' (default — deterministic 256-dim feature-hash, pure stdlib, no
# key needed; tests never call a real embeddings API) or 'openai-embed'.
@@ -103,3 +115,18 @@ AI_EMBED_PROVIDER=mock
# -H "Authorization: Bearer $AI_EMBED_API_KEY" \
# -H 'Content-Type: application/json' \
# -d '{"model":"'"$AI_EMBED_MODEL"'","input":["Nextcraft probe"]}' | jq -e '.data[0].embedding | length'
# --- Billing / Payment Engine (REQ-9-001, D-075, D-077) ---
# Payment provider: 'mock' (default — deterministic, zero-network; tests
# NEVER call a real payment API) or 'stripe' (real Stripe-compatible REST,
# P3+). Unknown names raise at the factory; the boot falls back to mock
# with a loud log (G-11).
# AI_PAYMENT_PROVIDER=mock
# Real provider API key (Stripe-style sk_test_... placeholder — NEVER a
# real key here; real keys live in .ciagent/.env.secrets, env-only per
# D-014, never logged, SecretStr in Settings). 'mock' ignores it.
# AI_PAYMENT_API_KEY=
# Webhook HMAC secret (whsec_... style; UNSET → per-boot random → signed
# webhooks FAIL CLOSED with 401 until configured — correct for a payment
# surface). Set a fixed value only when a provider actually delivers.
# AI_WEBHOOK_SECRET=
+1 -1
View File
@@ -265,4 +265,4 @@ scripts/ bootstrap.sh dev.sh test.sh lint.sh
tests/ pytest — mock provider only
```
Boundary rules: `llm/` imports nothing from `agents/` or `api/`; `agents/` imports nothing from `api/`.
Boundary rules: `llm/` imports nothing from `agents/` or `api/`; `agents/` imports nothing from `api/`.
+345
View File
@@ -0,0 +1,345 @@
"""/v1/billing — billing API (REQ-9-002/9-003, D-078/D-079/G-302).
P2: the public catalog read (advertising — no auth).
P3: the public webhook receiver. Webhook discipline (risk-1/G-302):
`await request.body()` FIRST — the exact un-parsed bytes verify the
HMAC before ANY parsing; unsigned/bad signatures 401, never process.
Event payloads are UNTRUSTED ID REFERENCES — session/subscription
state resolves from LOCAL rows; unknown targets land as `unmatched`
billing events (200, no entitlement). Provider event ids dedupe
verbatim (at-least-once tolerance, R-45); grants are idempotent per
SESSION (G-303 — the store's UniqueConstraints enforce exactly-once).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from ..billing.base import Package, Plan, WebhookSignatureError
from ..billing.store import BillingStore
from .deps import get_billing_store
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/billing", tags=["billing"])
class CatalogResponse(BaseModel):
"""The catalog wire shape (REQ-9-002; pinned field-for-field by
packages/types/billing.ts via apps/web/tests/pins.test.ts — G-108
discipline)."""
packages: list[Package]
plans: list[Plan]
@router.get("/catalog", response_model=CatalogResponse)
def get_catalog(
store: BillingStore = Depends(get_billing_store),
) -> CatalogResponse:
"""The seeded catalog, price-ascending (D-078 — one source of
truth; idempotent seeding happened at app boot)."""
packages, plans = store.list_catalog()
return CatalogResponse(packages=packages, plans=plans)
def _get_payment_provider(request: Request):
"""The selected provider via app.state DI (main.py wires the
factory + G-11 fallback)."""
return request.app.state.payment_provider
# -- webhook (Task 3-2-02, G-302) ----------------------------------------------
async def process_webhook_event(
*,
store: BillingStore,
provider: object,
raw_body: bytes,
signature_header: str,
) -> dict[str, object]:
"""The REAL webhook processing path (G-301's E2E proof runs through
here; the HTTP route + the mock-complete route share it).
Order (risk-1): verify HMAC over the exact bytes FIRST, then parse,
then dedupe, then process. 200 fast on every authenticated path —
duplicates and unknown targets are recorded, never errors (R-45's
at-least-once + G-306's unknown-target tolerance).
Crash-safety (final-review fix): the event id is recorded AFTER
its effects commit. The old order (record id → process) made a
crash between the two permanent: every retry deduped as
"duplicate" while the grant never happened — the entitlement was
lost forever. The new order keeps a bounded replay window: a
replay re-runs processing (a no-op for grants — G-303's per-session
UniqueConstraint is the exactly-once backstop) and re-records the
audit rows for that one event only until its id lands."""
from ..billing.base import PaymentProvider
assert isinstance(provider, PaymentProvider)
try:
verified = provider.verify_webhook(raw_body, signature_header)
except WebhookSignatureError as exc:
logger.warning("billing webhook rejected: %s", exc)
raise HTTPException(status_code=401, detail=str(exc)) from exc
except ValueError as exc:
# G-306 (never 500): a correctly-SIGNED event whose type the
# provider cannot normalize onto our canonical vocabulary
# (Stripe's real streams deliver many event types) is honest
# unmatched audit material, never a crashed receiver — Stripe
# disables endpoints on error storms. The transport was
# AUTHENTICATED (compare_digest passed) before the parse, so the
# failure is name-level, not trust-level. WebhookSignatureError
# is a ValueError subclass — the specific except above wins for
# actual signature failures (401), this arm only sees
# normalize_event's unmapped-name raise.
store.insert_billing_event(
"unmatched",
"unmapped.event",
detail={"reason": f"unmapped provider event: {exc}"},
)
return {"status": "unmatched", "processed": False}
event = verified.event
# Dedupe VERBATIM on the provider event id (R-43/D-083) — checked
# BEFORE the effects (fast-path a known replay), recorded AFTER
# they commit (the crash-safety half of the final-review fix). A
# crash between the two leaves the id UN-recorded, so the provider's
# retry re-runs the effects — a no-op for grants (G-303's
# per-session UniqueConstraint is the exactly-once backstop) — and
# lands the key then. The old order (record → process) made that
# crash permanent: every retry answered "duplicate" while the grant
# never happened.
if not store.has_provider_event(verified.provider_event_id):
result = _process_canonical_event(store, event.name, dict(event.payload))
store.insert_provider_event(
verified.provider_event_id, event.name, dict(event.payload)
)
return {"status": "ok", "processed": result}
return {"status": "duplicate", "processed": False}
def _process_canonical_event(
store: BillingStore, name: str, payload: dict[str, object]
) -> str:
"""G-302: the payload is UNTRUSTED — id references only. State
resolves from LOCAL rows; unknown targets land as unmatched audit
rows (never a 500, G-306)."""
if name == "checkout.completed":
session_id = str(payload.get("session_id", ""))
local = store.get_checkout_session_by_provider_id(session_id)
if local is None:
store.insert_billing_event(
"unmatched",
name,
detail={"reason": "unknown session", "session_id": session_id},
)
return "unmatched"
# R-37 predicate: complete AND paid — NEVER status alone (async
# methods can leave a session complete with payment still
# processing). Final-review fix: the payload's payment_status
# (attacker-immutable: it is inside the HMAC) gates the flip +
# the grant; an unpaid/processing completion is recorded, not
# granted, and a later paid delivery grants.
payment_status = str(payload.get("payment_status", "unpaid"))
if payment_status not in ("paid", "no_payment_required"):
store.insert_billing_event(
"unmatched",
name,
detail={
"reason": f"payment_status {payment_status!r} not paid",
"session_id": local.id,
},
)
return "processing"
# Status resolves from the LOCAL row's snapshot (the route flips
# it on completion), never the payload's other fields.
store.complete_checkout_session(local.id, payment_status=payment_status)
_grant_entitlement(store, local)
store.insert_billing_event(
"provider",
name,
employer_id=local.employer_id,
detail={"session_id": local.id},
)
return "granted"
if name == "invoice.paid":
subscription_ref = str(payload.get("subscription_id", ""))
extended = store.extend_subscription_period(subscription_ref, days=30)
if extended is None:
store.insert_billing_event(
"unmatched",
name,
detail={
"reason": "unknown subscription",
"subscription_id": subscription_ref,
},
)
return "unmatched"
store.insert_billing_event(
"provider", name, employer_id=extended.employer_id
)
return "renewed"
if name == "invoice.payment_failed":
subscription_ref = str(payload.get("subscription_id", ""))
subscription = store.get_subscription_by_provider_id(subscription_ref)
if subscription is None:
store.insert_billing_event(
"unmatched",
name,
detail={
"reason": "unknown subscription",
"subscription_id": subscription_ref,
},
)
return "unmatched"
try:
store.transition_subscription(subscription.id, "past_due")
except Exception:
# Already reflected (out-of-order tolerance, G-306) — record.
store.insert_billing_event(
"unmatched",
name,
detail={"reason": "already past_due", "subscription_id": subscription_ref},
)
return "already"
store.insert_billing_event(
"provider", name, employer_id=subscription.employer_id
)
return "past_due"
if name == "subscription.cancelled":
subscription_ref = str(payload.get("subscription_id", ""))
subscription = store.get_subscription_by_provider_id(subscription_ref)
if subscription is None:
store.insert_billing_event(
"unmatched",
name,
detail={
"reason": "unknown subscription",
"subscription_id": subscription_ref,
},
)
return "unmatched"
try:
store.transition_subscription(subscription.id, "cancelled")
except Exception:
store.insert_billing_event(
"unmatched",
name,
detail={"reason": "already cancelled", "subscription_id": subscription_ref},
)
return "already"
store.insert_billing_event(
"provider", name, employer_id=subscription.employer_id
)
return "cancelled"
if name == "subscription.renewed":
# R-43: renewal = invoice.paid on a recurring subscription; the
# corroborating customer.subscription.updated is recorded here.
return _process_canonical_event(store, "invoice.paid", payload)
store.insert_billing_event(
"unmatched", name, detail={"reason": "unhandled canonical name"}
)
return "unhandled"
def _grant_entitlement(store: BillingStore, session_row: object) -> str:
"""Create the purchase or subscription from the LOCAL snapshot
(D-078 prices; G-303 exactly-once per session — the store's
UniqueConstraint refuses a second grant; the refusal is the
already-granted answer, recorded not raised)."""
from sqlalchemy.exc import IntegrityError
from ..billing.store import CheckoutSessionRow
assert isinstance(session_row, CheckoutSessionRow)
if session_row.mode == "payment":
package, _, _item = store.get_catalog_item(session_row.item_id)
if package is not None:
try:
store.create_purchase_from_session(
session_row,
posts_limit=package.posts_limit,
duration_days=package.duration_days,
)
return "granted"
except IntegrityError:
# G-303: a distinct event id announced the SAME
# completion — the grant already happened; the 200 is
# the honest answer (never a 500, R-45).
store.insert_billing_event(
"unmatched",
"checkout.completed",
employer_id=session_row.employer_id,
detail={
"reason": "already granted",
"session_id": session_row.id,
},
)
return "already"
# Final-review fix: the item vanished from the catalog AFTER the
# checkout row snapshotted it (an operator deleted a seed id) —
# record the anomaly; the caller's "granted"-shaped answer must
# never silently mask a missing grant (the audit log answers
# "why does this employer have no entitlement?").
store.insert_billing_event(
"unmatched",
"checkout.completed",
employer_id=session_row.employer_id,
detail={
"reason": "catalog item missing at grant time",
"item_id": session_row.item_id,
"session_id": session_row.id,
},
)
return "no-item"
_, plan, _item = store.get_catalog_item(session_row.item_id)
if plan is not None:
# Upsert keyed on the provider ref — replay-safe by construction.
# Final-review fix: the ref is the mock provider's DOCUMENTED
# convention (`sub_mock_{session_id without its cs_ prefix}`,
# mock.py confirm_completion) — the old `sub_prov_…` spelling
# made the store row's provider_subscription_id a key the
# provider never knew, so every provider lifecycle call
# (cancel/update) resolved None and desynced from local state.
store.create_subscription_from_session(
session_row,
provider_subscription_id=(
f"sub_mock_{session_row.provider_session_id.removeprefix('cs_')}"
),
interval=plan.interval,
)
return "granted"
store.insert_billing_event(
"unmatched",
"checkout.completed",
employer_id=session_row.employer_id,
detail={
"reason": "catalog item missing at grant time",
"item_id": session_row.item_id,
"session_id": session_row.id,
},
)
return "no-item"
@router.post("/webhook")
async def billing_webhook(
request: Request,
store: BillingStore = Depends(get_billing_store),
) -> Response:
"""Public provider webhook receiver (server-to-server; CORS
irrelevant). Risk-1 discipline: the RAW BODY is read + verified
BEFORE any parsing — a pre-parsed body breaks the HMAC."""
raw_body = await request.body()
signature = request.headers.get("Stripe-Signature", "")
await process_webhook_event(
store=store,
provider=_get_payment_provider(request),
raw_body=raw_body,
signature_header=signature,
)
return Response(content='{"received": true}', media_type="application/json")
@@ -0,0 +1,536 @@
"""/v1/billing/admin — operator billing API (REQ-9-003, D-076).
Operator-mediated checkout: at pilot there are no employer principals
(A-407 — employers are data, not principals), so the operator buys on
an employer's behalf. Every route sits behind `require_admin`
(fail-closed, R-51 — copied verbatim from marketplace_admin.py); the
checkout records operator_ref + employer_id provenance (D-076).
The mock-complete route (D-082, amended G-301) is admin-gated AND
mounted only under provider=mock — the operator completing a session
after out-of-band payment is the honest pilot flow.
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from ..billing.base import (
CheckoutSessionSpec,
)
from ..billing.store import BillingStore
from ..config import Settings
from .deps import get_billing_store
from .marketplace_admin import require_admin
router = APIRouter(prefix="/v1/billing/admin", tags=["billing-admin"])
# -- contracts ----------------------------------------------------------------
class CheckoutCreate(BaseModel):
"""The operator's checkout request (D-076): which catalog item, on
behalf of which employer."""
item_id: str = Field(min_length=1)
employer_id: str = Field(min_length=1)
class CheckoutResponse(BaseModel):
"""The created session handle + the local reconciliation id."""
session_id: str
provider_session_id: str
url: str
mode: str
class MockCompleteRequest(BaseModel):
"""D-082/G-301: the operator completes a mock session after
out-of-band payment. Admin-gated + mock-provider-only."""
provider_session_id: str = Field(min_length=1)
class MockCompleteResponse(BaseModel):
completed: bool
payment_status: str
class EntitlementResponse(BaseModel):
"""The per-employer entitlement summary (REQ-9-004): active
purchase (posts used/limit/expiry) + subscription state. The admin
surface renders this (G-308's token-gated fetch)."""
employer_id: str
purchase: dict[str, object] | None
subscription: dict[str, object] | None
# -- checkout (Task 3-2-01, G-310) ---------------------------------------------
@router.post("/checkout", status_code=201, response_model=CheckoutResponse)
async def create_checkout(
body: CheckoutCreate,
request: Request,
store: BillingStore = Depends(get_billing_store),
settings: Settings = Depends(require_admin),
) -> CheckoutResponse:
"""Create a provider checkout session for a catalog item on an
employer's behalf (D-076). The catalog item snapshot (name +
price_cents, D-078) rides the session and the local reconciliation
row; success/cancel URLs default from public_base_url (G-310)."""
package, plan, item = store.get_catalog_item(body.item_id)
if item is None:
raise HTTPException(status_code=404, detail="unknown catalog item")
mode = "subscription" if item["kind"] == "plan" else "payment"
interval: str = str(item.get("interval", "") or "")
if settings.public_base_url:
success_url = (
f"{settings.public_base_url}/marketplace/pricing?checkout=success"
)
cancel_url = (
f"{settings.public_base_url}/marketplace/pricing?checkout=cancelled"
)
else:
# Mock pilot path: the mock provider never redirects; empty is
# honest (stripe factory validation rejects the empty case —
# G-310's loud boot warning covers the stripe selection).
success_url = ""
cancel_url = ""
# The reconciliation key is the LOCAL row id (R-34): persist the
# snapshot row FIRST, then mint the spec against it. The provider
# session id is bound AFTER the provider call — until then the
# placeholder must be UNIQUE (final-review fix: a bare "pending"
# collided on the provider_session_id UNIQUE constraint the moment
# two checkouts raced — the second admin POST 500'd with
# IntegrityError; a fresh uuid per row makes the placeholder a
# proper placeholder).
row = store.insert_checkout_session(
provider_session_id=f"pending-{uuid.uuid4().hex[:16]}",
employer_id=body.employer_id,
item_id=str(item["id"]),
item_name=str(item["name"]),
price_cents=int(item["price_cents"]),
mode=mode,
)
spec = CheckoutSessionSpec(
mode=mode, # type: ignore[arg-type]
item_id=str(item["id"]),
price_cents=int(item["price_cents"]),
item_name=str(item["name"]),
interval=interval,
success_url=success_url,
cancel_url=cancel_url,
client_reference_id=row.id,
metadata={
"employer_id": body.employer_id,
"operator_ref": "operator",
},
)
provider = request.app.state.payment_provider
session = await provider.create_checkout_session(spec)
# Pin the provider session id onto the local row (upsert by local
# id — the row IS the reconciliation record, G-302).
store.bind_provider_session_id(row.id, session.id)
return CheckoutResponse(
session_id=row.id,
provider_session_id=session.id,
url=session.url,
mode=mode,
)
# -- mock-complete (D-082, amended G-301) ---------------------------------------
@router.post(
"/mock/complete", status_code=200, response_model=MockCompleteResponse
)
async def mock_complete(
body: MockCompleteRequest,
request: Request,
store: BillingStore = Depends(get_billing_store),
settings: Settings = Depends(require_admin),
) -> MockCompleteResponse:
"""Complete a MOCK checkout session after out-of-band payment
(D-082/G-301): admin-gated, mounted only under provider=mock —
this route 404s on a stripe-configured box (the factory-conditional
mount lives in main.py). The completion mints a properly-signed
event through the REAL webhook processing path."""
from ..billing.mock import MockPaymentProvider
provider = request.app.state.payment_provider
if not isinstance(provider, MockPaymentProvider):
# main.py gates the mount; this arm is defense-in-depth.
raise HTTPException(
status_code=404, detail="mock-complete exists only under provider=mock"
)
session = store.get_checkout_session_by_provider_id(body.provider_session_id)
if session is None:
raise HTTPException(status_code=404, detail="unknown checkout session")
try:
session_after = await provider.confirm_completion(
body.provider_session_id
)
except ValueError as exc:
# G-301 idempotency (the mock's documented contract: the ROUTE
# owns it): the provider refuses a re-completion ("not open" —
# operator double-click) or lost its in-memory state across a
# restart ("unknown session"). The provider's CURRENT view is
# authoritative — resolve via retrieve_session and re-run the
# REAL webhook path (idempotent per session, G-303); NEVER a
# 500 on the payment surface (G-306).
recovered = await provider.retrieve_session(body.provider_session_id)
if recovered is None:
raise HTTPException(
status_code=404,
detail=(
"the provider no longer knows this session (a restart "
"clears mock state) — create a new checkout"
),
) from exc
if recovered.status != "complete":
raise HTTPException(
status_code=409,
detail=f"session is {recovered.status!r}, not completable",
) from exc
# Already complete at the provider: fall through with the
# recovered truth — the re-minted event re-runs the grant path,
# and G-303's per-session exactly-once refuses a second grant.
session_after = recovered
# Mint + process the signed event through the REAL webhook path
# (direct handler call — same code path, G-301's E2E proof).
from .billing import process_webhook_event
raw_body, header, event_id = provider.mint_event(
"checkout.completed",
{
"session_id": body.provider_session_id,
"payment_status": session_after.payment_status,
},
)
await process_webhook_event(
store=store,
provider=provider,
raw_body=raw_body,
signature_header=header,
)
return MockCompleteResponse(
completed=session_after.status == "complete",
payment_status=session_after.payment_status,
)
# -- entitlements (Task 4-1-02) ------------------------------------------------
@router.get(
"/entitlements/{employer_id}", status_code=200, response_model=EntitlementResponse
)
def get_entitlement(
employer_id: str,
store: BillingStore = Depends(get_billing_store),
settings: Settings = Depends(require_admin),
) -> EntitlementResponse:
"""The employer's entitlement state: active package (posts used/
limit/expiry — lazy past_due grace per G-306) + subscription."""
entitlement = store.get_entitlement(employer_id)
return EntitlementResponse(
employer_id=entitlement["employer_id"],
purchase=entitlement["purchase"],
subscription=entitlement["subscription"],
)
# -- subscription lifecycle + manual grants + event log (Task 5-1-02) -------------
class LifecycleResponse(BaseModel):
"""A provider-backed or manual lifecycle transition result."""
subscription_id: str
status: str
provider_action: str | None = None
class ManualGrantRequest(BaseModel):
"""D-080's manual path: the operator grants enterprise unlimited
posts or a plan on an employer (remediation for failed webhooks —
D-083: recorded with provenance)."""
note: str = ""
@router.post(
"/subscriptions/{subscription_id}/cancel",
status_code=200,
response_model=LifecycleResponse,
)
async def cancel_subscription(
subscription_id: str,
request: Request,
store: BillingStore = Depends(get_billing_store),
settings: Settings = Depends(require_admin),
) -> LifecycleResponse:
"""Cancel a subscription (R-46): the provider call first (stripe:
DELETE / the mock: state-only), then the local transition. Manual
cancellation records provenance (D-083)."""
subscription = store.get_subscription(subscription_id)
if subscription is None:
raise HTTPException(status_code=404, detail="unknown subscription")
provider_action: str | None = None
provider = request.app.state.payment_provider
if hasattr(provider, "cancel_subscription"):
ref = await provider.cancel_subscription(subscription.provider_subscription_id)
provider_action = f"cancel:{ref.status}" if ref else "cancel:unknown"
try:
updated = store.transition_subscription(subscription_id, "cancelled")
except Exception as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
assert updated is not None
store.insert_billing_event(
"manual",
"cancel",
employer_id=subscription.employer_id,
operator="operator",
detail={"subscription_id": subscription_id},
)
return LifecycleResponse(
subscription_id=subscription_id,
status=updated.status,
provider_action=provider_action,
)
@router.post(
"/subscriptions/{subscription_id}/resume",
status_code=200,
response_model=LifecycleResponse,
)
def resume_subscription(
subscription_id: str,
store: BillingStore = Depends(get_billing_store),
settings: Settings = Depends(require_admin),
) -> LifecycleResponse:
"""Resume past_due → active (the recovery transition; only the
matrix's legal moves — InvalidTransition is the honest 409)."""
subscription = store.get_subscription(subscription_id)
if subscription is None:
raise HTTPException(status_code=404, detail="unknown subscription")
try:
updated = store.transition_subscription(subscription_id, "active")
except Exception as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
assert updated is not None
store.insert_billing_event(
"manual",
"resume",
employer_id=subscription.employer_id,
operator="operator",
detail={"subscription_id": subscription_id},
)
return LifecycleResponse(subscription_id=subscription_id, status=updated.status)
class UpgradeRequest(BaseModel):
plan_id: str = Field(min_length=1)
@router.post(
"/subscriptions/{subscription_id}/upgrade",
status_code=200,
response_model=LifecycleResponse,
)
async def upgrade_subscription(
subscription_id: str,
body: UpgradeRequest,
request: Request,
store: BillingStore = Depends(get_billing_store),
settings: Settings = Depends(require_admin),
) -> LifecycleResponse:
"""G-305: upgrade is a plan_id transition WITHIN active (never a
status move); the store records the manual billing_event with
provenance. Stripe: the provider's update_subscription resolves
items[0][id] inside (R-47 — the dual-charge guard)."""
subscription = store.get_subscription(subscription_id)
if subscription is None:
raise HTTPException(status_code=404, detail="unknown subscription")
_package, plan, item = store.get_catalog_item(body.plan_id)
if plan is None or item is None:
raise HTTPException(status_code=404, detail="unknown plan")
provider = request.app.state.payment_provider
if hasattr(provider, "update_subscription"):
await provider.update_subscription(
subscription.provider_subscription_id, int(item["price_cents"])
)
try:
updated = store.upgrade_subscription(
subscription_id,
body.plan_id,
str(item["name"]),
int(item["price_cents"]),
)
except Exception as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
assert updated is not None
return LifecycleResponse(
subscription_id=subscription_id,
status=updated.status,
provider_action=f"update:{body.plan_id}",
)
@router.post(
"/employers/{employer_id}/grant",
status_code=201,
response_model=EntitlementResponse,
)
def manual_grant(
employer_id: str,
request: Request,
store: BillingStore = Depends(get_billing_store),
settings: Settings = Depends(require_admin),
) -> EntitlementResponse:
"""D-080's manual unlimited grant (the enterprise path — operator
remediation for failed webhooks; D-083: manual provenance in the
audit log)."""
session_row = store.insert_checkout_session(
# Final-review fix: a per-call unique placeholder — a second
# grant for the same employer collided on the
# provider_session_id UNIQUE constraint (500) where the route
# promises idempotent re-grants.
provider_session_id=f"cs_manual_{uuid.uuid4().hex[:16]}",
employer_id=employer_id,
item_id="pkg-enterprise",
item_name="Enterprise (manual grant)",
price_cents=0,
mode="payment",
)
store.create_purchase_from_session(
session_row, posts_limit=None, duration_days=None
)
store.insert_billing_event(
"manual",
"grant",
employer_id=employer_id,
operator="operator",
detail={"grant": "unlimited"},
)
entitlement = store.get_entitlement(employer_id)
return EntitlementResponse(
employer_id=employer_id,
purchase=entitlement["purchase"],
subscription=entitlement["subscription"],
)
@router.post(
"/employers/{employer_id}/revoke",
status_code=200,
response_model=EntitlementResponse,
)
def manual_revoke(
employer_id: str,
store: BillingStore = Depends(get_billing_store),
settings: Settings = Depends(require_admin),
) -> EntitlementResponse:
"""Revoke every purchase grant for the employer (manual remediation
— the inverse of the grant; provenance recorded)."""
from sqlalchemy import text as _text
from sqlmodel import Session as _Session
# Final-review fix: bind a NAIVE utcnow — the same format the ORM's
# sqlite DATETIME processor writes. The old aware bind (+00:00
# suffix) left mixed-format TEXT in the column; comparisons stayed
# correct in practice (ISO prefix ordering) but the column held two
# spellings of the same field. One format, one discipline.
with _Session(store._engine) as session: # type: ignore[attr-defined]
session.execute(
_text(
"UPDATE billing_purchases SET expires_at = :past "
"WHERE employer_id = :employer_id"
),
{
"past": _utc_now_minus_day().replace(tzinfo=None),
"employer_id": employer_id,
},
)
session.commit()
store.insert_billing_event(
"manual",
"revoke",
employer_id=employer_id,
operator="operator",
detail={"revoke": "all purchases"},
)
entitlement = store.get_entitlement(employer_id)
return EntitlementResponse(
employer_id=employer_id,
purchase=entitlement["purchase"],
subscription=entitlement["subscription"],
)
def _utc_now_minus_day():
"""Naive-UTC (matches the ORM's sqlite DATETIME storage format —
see the revoke route's binding note)."""
from datetime import UTC, datetime, timedelta
return datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1)
class BillingEventRow_(BaseModel):
"""The audit-trail wire row (REQ-9-005's queryable log)."""
id: int
employer_id: str | None
kind: str
name: str
detail: dict[str, object]
operator: str | None
created_at: str | None
class BillingEventListResponse(BaseModel):
events: list[BillingEventRow_]
count: int
@router.get(
"/events", status_code=200, response_model=BillingEventListResponse
)
def list_events(
employer_id: str | None = None,
kind: str | None = None,
name: str | None = None,
limit: int = 100,
store: BillingStore = Depends(get_billing_store),
settings: Settings = Depends(require_admin),
) -> BillingEventListResponse:
"""The billing event log (REQ-9-005): every provider event, manual
action (with operator provenance), and unmatched delivery —
filterable by employer/kind/name."""
rows = store.list_billing_events(
employer_id=employer_id, kind=kind, name=name, limit=limit
)
return BillingEventListResponse(
events=[
BillingEventRow_(
id=r.id or 0,
employer_id=r.employer_id,
kind=r.kind,
name=r.name,
detail=dict(r.detail),
operator=r.operator,
created_at=r.created_at.isoformat() if r.created_at else None,
)
for r in rows
],
count=len(rows),
)
+22
View File
@@ -5,12 +5,15 @@ from fastapi import Request
from ..agents.examiner import ExaminerAgent
from ..agents.registry import AgentRegistry
from ..agents.session import SessionStore
from ..billing.store import BillingStore
from ..config import Settings
from ..employers.store import EmployerStore
from ..grading.engine import GradingEngine
from ..grading.store import GradeStore
from ..llm.base import LLMProvider
from ..marketplace.embedding.base import EmbeddingProvider
from ..marketplace.store import MarketplaceStore
from ..placements.store import PlacementStore
from ..sandbox.manager import SandboxManager
from ..sandbox.workdir import SandboxDir
from ..telemetry.ingest import TraceIntegrityMap
@@ -87,6 +90,25 @@ def get_marketplace_store(request: Request) -> MarketplaceStore:
return request.app.state.marketplace_store
def get_billing_store(request: Request) -> BillingStore:
"""v0.9 billing (D-080): the same app.state DI territory rule —
billing routes compose the store, never import it."""
return request.app.state.billing_store
def get_employer_store(request: Request) -> EmployerStore:
"""v0.10 employers (D-087): the same app.state DI territory rule —
employer routes compose the store via app.state, never import it."""
return request.app.state.employer_store
def get_placement_store(request: Request) -> PlacementStore:
"""v0.11 placements (D-100/D-112): the same app.state DI territory
rule — placement routes compose the store via app.state, never
import it."""
return request.app.state.placement_store
def get_embedding_provider(request: Request) -> EmbeddingProvider:
"""v0.6 P3 (D-051): the embedding port via app.state DI — the
search route + publish hooks compose it, never import it."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
"""/v1/employers/admin — employer claim operator surface (REQ-10-002, D-089/D-094).
Every route sits behind `require_admin` IMPORTED from
marketplace_admin (the billing_admin.py:27 precedent, D-094 — one
guard, never reimplemented): fail-closed 403 on no/missing/invalid
token — the R-56 matrix; an UNSET AI_ADMIN_TOKEN 403s every route
even with a candidate (the main.py boot warning covers the config
class). Operator routes compose NO learner gate (D-097) and no
employer-session gate — a different principal class, and the token
namespaces cannot collide (this gate compares bytes; the session
gate hash-looks-up — D-090).
D-089: evidence is ADVISORY — the queue returns the stored claim-time
provenance snapshot as-is; approval is the EXPLICIT operator decision
(auto-approval deferred: without email verification the match is
forgeable, A-304 — never present unverified as verified). Every
decision appends an employer_audit_events row with operator
provenance in the SAME transaction as the claim update (decide_claim
— all-or-nothing). The accounts wire contract carries NO password_hash
(hash-at-rest, D-087 — never returned anywhere).
409 discipline (the decide arms' honest constraint 409s — details
carry constraint names only, never account identifiers):
- claim not pending (decided twice) → "claim is already decided"
- the deciding account already holds an entity (the one-account-
one-entity guard) → "the claim's account already holds an employer
entity"
- the entity already owned (the employer_accounts.employer_id UNIQUE
surfacing on the approve flush) → "the employer entity is already
owned" — surfaced, NEVER silently overwritten.
Any OTHER IntegrityError re-raises (a 500) — never mask an unknown
constraint violation as a decision.
"""
from __future__ import annotations
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from ..config import Settings
from ..employers.base import EmployerClaim
from ..employers.store import ClaimRow, EmployerStore
from .deps import get_employer_store
from .marketplace_admin import require_admin
router = APIRouter(prefix="/v1/employers/admin", tags=["employers-admin"])
#: Operator provenance stamped on every decision + audit event (the
#: billing_admin "operator" precedent — route-level provenance; no
#: per-operator accounts exist at pilot, D-094).
OPERATOR = "operator"
# -- contracts ----------------------------------------------------------------
class AdminEmployerAccount(BaseModel):
"""The accounts-list wire row (D-094): email, claimed entity,
created_at, the UNVERIFIED marker. `email_verified` is
`Literal[False]` (D-097 — structurally incapable of True: no email
verification exists in v0.10, so the field can never carry it).
password_hash is NEVER on the wire (D-087)."""
id: str
email: str
employer_id: str | None
created_at: str
#: D-097: the account is UNVERIFIED — displayed as such, never as
#: verified (A-304's doctrine applied to the account surface).
email_verified: Literal[False] = False
class AdminAccountsResponse(BaseModel):
accounts: list[AdminEmployerAccount]
count: int
class AdminClaimsResponse(BaseModel):
"""The claim queue (D-089): status, the stored claim-time evidence
snapshot — the domain-match signal rides evidence["match"],
ADVISORY, never a verification — and the decided state
(decided_by/decided_at, None while pending)."""
claims: list[EmployerClaim]
count: int
class ClaimDecisionResponse(BaseModel):
"""The decided claim (status approved/rejected + operator
provenance) — either decision arm returns the same shape."""
claim: EmployerClaim
class AdminAuditEvent(BaseModel):
"""The audit-trail wire row (D-089's queryable operator trail —
the BillingEventRow_ shape, billing_admin.py:488)."""
id: int
account_id: str | None
employer_id: str | None
kind: str
name: str
detail: dict[str, object]
operator: str | None
created_at: str | None
class AdminAuditResponse(BaseModel):
events: list[AdminAuditEvent]
count: int
def _claim_to_contract(row: ClaimRow) -> EmployerClaim:
"""Store row → wire contract (created_at is always set on a row
the store returned — insert/decide stamp it)."""
assert row.created_at is not None # the store always stamps it
return EmployerClaim(
id=row.id,
account_id=row.account_id,
employer_id=row.employer_id,
status=row.status, # type: ignore[arg-type]
evidence=dict(row.evidence or {}),
created_at=row.created_at,
decided_by=row.decided_by,
decided_at=row.decided_at,
)
# -- queue reads (D-094) ---------------------------------------------------------
@router.get("/accounts", response_model=AdminAccountsResponse)
def list_accounts(
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
) -> AdminAccountsResponse:
"""The accounts list: email, claimed entity, created_at, and the
UNVERIFIED marker (D-097 — account emails are unverified and
displayed as such; a claimed entity column shows the APPROVED
ownership binding, never account verification)."""
rows = store.list_accounts()
return AdminAccountsResponse(
accounts=[
AdminEmployerAccount(
id=account.id,
email=account.email,
employer_id=account.employer_id,
created_at=account.created_at.isoformat() if account.created_at else "",
email_verified=False, # D-097: UNVERIFIED, always False
)
for account in rows
],
count=len(rows),
)
@router.get("/claims", response_model=AdminClaimsResponse)
def list_claims(
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
status: str | None = None,
) -> AdminClaimsResponse:
"""The claim queue: status, the stored evidence snapshot (the
domain-match signal is ADVISORY — the signal informs, the operator
decides; match=false is the example.com corpus norm, R-65), and
the decided state. Optional ?status= facet (the admin queue's
consume-free read path)."""
rows = store.list_claims(status=status)
return AdminClaimsResponse(
claims=[_claim_to_contract(row) for row in rows],
count=len(rows),
)
# -- decisions (D-089 — the explicit operator action) ----------------------------
def _decide(store: EmployerStore, claim_id: str, decision: str) -> EmployerClaim:
"""The shared decide path: unknown claim → 404; the store's guard
arms + the employer_accounts.employer_id UNIQUE surface as the
honest 409s (constraint names only — never account identifiers);
decide_claim appends the operator audit event in the SAME
transaction as the decision."""
try:
row = store.decide_claim(claim_id, decision=decision, operator=OPERATOR)
except IntegrityError as exc:
message = str(getattr(exc, "orig", exc))
if "employer_accounts.employer_id already set" in message:
# decide_claim's account-already-owner guard — the
# one-account-one-entity arm, hand-raised by the store.
raise HTTPException(
status_code=409,
detail=("the claim's account already holds an employer entity"),
) from None
if "UNIQUE constraint failed: employer_accounts.employer_id" in (message):
# The approve flush refused: a DIFFERENT account already
# owns the entity — surfaced, never silently overwritten.
raise HTTPException(
status_code=409,
detail="the employer entity is already owned",
) from None
if "is already" in message:
# decide_claim's not-pending guard — deciding a decided
# claim, the IntegrityError shape (the honest 409).
raise HTTPException(
status_code=409,
detail="claim is already decided",
) from None
# Unmapped IntegrityError — re-raise (a 500): never mask an
# unknown constraint violation as a decision.
raise
if row is None:
raise HTTPException(status_code=404, detail="unknown claim")
return _claim_to_contract(row)
@router.post("/claims/{claim_id}/approve", response_model=ClaimDecisionResponse)
def approve_claim(
claim_id: str,
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
) -> ClaimDecisionResponse:
"""Approve → the claim's account.employer_id is set (one account :
one entity — the UNIQUE activates) + decided_by/decided_at + the
operator audit event, all in one transaction. Evidence never
auto-activates anything: the operator decision is the action."""
row = _decide(store, claim_id, "approved")
return ClaimDecisionResponse(claim=row)
@router.post("/claims/{claim_id}/reject", response_model=ClaimDecisionResponse)
def reject_claim(
claim_id: str,
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
) -> ClaimDecisionResponse:
"""Reject → decided_by/decided_at + the operator audit event (same
transaction); the entity is FREED for a new claim (the partial
pending index never touches rejected rows, G-316)."""
row = _decide(store, claim_id, "rejected")
return ClaimDecisionResponse(claim=row)
# -- audit trail (D-089's queryable operator trail) ------------------------------
@router.get("/audit", response_model=AdminAuditResponse)
def list_audit(
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
account_id: str | None = None,
employer_id: str | None = None,
kind: str | None = None,
name: str | None = None,
limit: int = 100,
) -> AdminAuditResponse:
"""The queryable audit trail: every auth/claim event with operator
provenance — the claim.submitted/claim.approved/claim.rejected
names; decided_by/decided_at ride the claim rows the decision
events reference (detail.claim_id joins them)."""
rows = store.list_audit_events(
account_id=account_id,
employer_id=employer_id,
kind=kind,
name=name,
limit=limit,
)
return AdminAuditResponse(
events=[
AdminAuditEvent(
id=event.id or 0,
account_id=event.account_id,
employer_id=event.employer_id,
kind=event.kind,
name=event.name,
detail=dict(event.detail or {}),
operator=event.operator,
created_at=event.created_at.isoformat() if event.created_at else None,
)
for event in rows
],
count=len(rows),
)
@@ -0,0 +1,99 @@
"""The D-091 shared posting-entitlement gate (REQ-10-004, R-58).
ONE helper, TWO principal gates: the admin create route (v0.9,
marketplace_admin) and the employer create route (v0.10 Task 4-1-02)
both compose THIS — never a re-implemented copy (the no-drift shape;
extraction is the only way "the exact v0.9 check parameterized" holds).
The helper lives in api/ beside both callers — no billing/→api/
boundary violation (billing/ stays ignorant of routes, D-027/D-048).
Wire contract (R-58 — byte-stable, D-080/D-084): the 402 detail dict is
{reason, posts_used: 0, posts_limit: 0, upgrade_cta} with posts_used and
posts_limit HARDCODED 0 in BOTH arms. The zeros are the v0.9 wire shape
the web client's UpgradeRequiredError ignores (it consumes reason +
upgrade_cta only, apps/web/lib/engine-client.ts) — do NOT "improve" them
into live counts; the payload-parity test pins the bytes.
Edge ordering (mirrors the extracted v0.9 gate exactly):
1. store-missing (None) → 503 fail-closed BEFORE any consume attempt —
a payment gate never silently skips (the final-review fix).
2. consume_posting → NoActivePackage → the no_active_package 402 arm.
3. consume_posting → PackageExhausted → the posting_limit_reached arm.
The gate runs BEFORE the caller's insert — a 402 consumes nothing,
creates nothing. `consume_posting` itself (G-304 single conditional
UPDATE) is untouched; this helper only maps its exceptions to the
honest 402 arms.
"""
from __future__ import annotations
from fastapi import HTTPException
from ..billing.store import BillingStore, NoActivePackage, PackageExhausted
def consume_posting_entitlement(
store: BillingStore | None,
employer_id: str,
upgrade_cta: str,
) -> None:
"""Spend one posting post for `employer_id`, or raise the honest 402.
Args:
store: the billing store from app.state (None = a wiring miss →
503 fail-closed — posting creation is refused until the
service is restarted, never an ungated insert).
employer_id: the entity whose package is charged.
upgrade_cta: the caller-principal's checkout pointer
("/v1/billing/admin/checkout" from the admin route; the
employer route passes its own) — interpolated into the two
FIXED per-arm sentences, so the rendered upgrade_cta field
differs per principal while reason/posts_used/posts_limit
stay byte-identical across both gates (the D-091 parity).
Raises:
HTTPException 503: the store is missing (fail-closed).
HTTPException 402: no active package / all posts spent — the
canonical dict payload (hardcoded zeros, R-58).
"""
if store is None:
raise HTTPException(
status_code=503,
detail=(
"billing entitlement store is unavailable — posting "
"creation is refused until the service is restarted "
"(the store wires at boot)"
),
)
try:
store.consume_posting(employer_id)
except NoActivePackage:
raise HTTPException(
status_code=402,
detail={
"reason": "no_active_package",
# R-58: hardcoded zeros — the wire contract, never live
# counts (real limits render only from the entitlement
# view; the client's UpgradeRequiredError consumes
# reason + upgrade_cta alone).
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer has no active posting package — "
f"purchase one via {upgrade_cta}."
),
},
) from None
except PackageExhausted:
raise HTTPException(
status_code=402,
detail={
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer's posting package is exhausted — "
f"buy more posts or upgrade via {upgrade_cta}."
),
},
) from None
@@ -16,6 +16,7 @@ from __future__ import annotations
import time
from collections import deque
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field, ValidationError
@@ -33,6 +34,15 @@ from ..marketplace.resume import (
parse_resume,
)
from ..marketplace.search import FacetFilters, rank_postings
from ..placements.base import (
InvalidTransition,
LearnerApplicationStatusResponse,
LearnerApplicationSummary,
MineApplicationResponse,
PlacementEventRead,
PlacementRead,
)
from ..placements.store import PlacementRow
from .deps import get_embedding_provider, get_marketplace_store, get_provider, get_settings
from .identity import require_verified_age
@@ -375,6 +385,19 @@ async def apply_posting(
application, _created = store.insert_application(
posting_id=body.posting_id, learner_id=body.learner_id
)
# G-323 (binding): the idempotent arm NEVER confirms a terminal
# application — a withdrawn/rejected/hired row returns 409 (the
# learner is told the honest state; recovery is visibility via the
# application-status surface, not resurrection — no second row is
# ever created; the (posting_id, learner_id) unique key stands).
if application.status in ("withdrawn", "rejected", "hired"):
raise HTTPException(
status_code=409,
detail={
"reason": "application_terminal",
"status": application.status,
},
)
return ApplyResponse(
application_id=application.id,
posting_id=application.posting_id,
@@ -382,6 +405,173 @@ async def apply_posting(
)
# -- v0.11 P2: the learner withdraw route (D-104, REQ-11-002) --------------------
class WithdrawBody(BaseModel):
"""D-104: the pilot surface passes learner_id in the body (the G-5
composition unchanged — the SAME stack as apply)."""
learner_id: str = Field(min_length=1)
@router.post(
"/applications/{application_id}/withdraw", response_model=ApplyResponse
)
async def withdraw_application(
application_id: str,
body: WithdrawBody,
request: Request,
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> ApplyResponse:
"""The learner's ONLY edit (D-099: withdrawn is learner- or
operator-triggerable, NEVER an employer session). The D-043
composition UNCHANGED: G-5 allowlist → verified 18+ → the EXISTING
apply-rate window (no new throttle knob, D-088 scope untouched).
Own application only (403 foreign); terminal → 409 (the matrix).
"""
_enforce_allowlist(body.learner_id, settings)
await require_verified_age(18, body.learner_id, _identity_store(request))
_check_apply_rate(body.learner_id)
application = store.get_application(application_id)
if application is None:
raise HTTPException(
status_code=404, detail=f"no application with id {application_id!r}"
)
if application.learner_id != body.learner_id:
raise HTTPException(
status_code=403,
detail="this application belongs to another learner",
)
from .deps import get_placement_store # local import — the territory rule
placements = get_placement_store(request)
try:
application, _placement = placements.transition_application(
application_id,
"withdrawn",
actor_type="learner",
actor_ref=f"learner:{body.learner_id}",
)
except InvalidTransition as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return ApplyResponse(
application_id=application.id,
posting_id=application.posting_id,
status=application.status,
)
# -- v0.11 P5: the learner status reads (D-104/G-322, REQ-11-005) -------------------
def _placement_read(row: PlacementRow) -> PlacementRead:
"""The PlacementRow → PlacementRead conversion (the placements_admin
helper mirrored — each route file owns its readers; start_date is
the ISO DATE string on the row, a date on the wire, D-103)."""
return PlacementRead(
id=row.id,
learner_id=row.learner_id,
employer_id=row.employer_id,
posting_id=row.posting_id,
application_id=row.application_id,
role_title=row.role_title,
start_date=date.fromisoformat(row.start_date),
status=row.status, # type: ignore[arg-type]
created_at=row.created_at or _now(),
decided_at=row.decided_at or _now(),
)
def _now():
from datetime import UTC, datetime
return datetime.now(UTC)
@router.get("/applications/mine", response_model=MineApplicationResponse)
async def mine_application(
request: Request,
posting_id: str = Query(min_length=1),
learner_id: str = Query(min_length=1),
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> MineApplicationResponse:
"""G-322 (binding): the mine point-read — the status panel's
application DISCOVERY on revisit (the panel NEVER depends on
client-persisted application_ids). Gate composition: G-5 allowlist
→ verified 18+ (reads are free — the apply-rate window is the
WRITE budget, A-408's distinct-action rule; no new throttle knob,
D-088 untouched); no row → the honest 404 (the surface renders the
apply CTA)."""
_enforce_allowlist(learner_id, settings)
await require_verified_age(18, learner_id, _identity_store(request))
application = store.get_application_by_posting_learner(posting_id, learner_id)
if application is None:
raise HTTPException(
status_code=404,
detail=f"no application by this learner on posting {posting_id!r}",
)
return MineApplicationResponse(
application_id=application.id,
status=application.status,
)
@router.get(
"/applications/{application_id}",
response_model=LearnerApplicationStatusResponse,
)
async def learner_application_status(
application_id: str,
request: Request,
learner_id: str = Query(min_length=1),
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> LearnerApplicationStatusResponse:
"""D-104 (REQ-11-005): the learner's-own outcome read — current
status + the FULL id-ordered timestamped history for THIS
application + the placement record when hired (one read, one
truth source). Same gates as the mine-read (reads are free);
foreign learner_id → 403 (the scoped query never renders another
learner's events); unknown → 404."""
_enforce_allowlist(learner_id, settings)
await require_verified_age(18, learner_id, _identity_store(request))
application = store.get_application(application_id)
if application is None:
raise HTTPException(
status_code=404, detail=f"no application with id {application_id!r}"
)
if application.learner_id != learner_id:
raise HTTPException(
status_code=403,
detail="this application belongs to another learner",
)
from .deps import get_placement_store # local import — the territory rule
placements = get_placement_store(request)
events = [
PlacementEventRead.model_validate(event, from_attributes=True)
for event in placements.get_application_events(application_id)
]
row = placements.get_placement_by_application(application_id)
return LearnerApplicationStatusResponse(
application=LearnerApplicationSummary(
id=application.id,
posting_id=application.posting_id,
learner_id=application.learner_id,
status=application.status,
created_at=application.created_at,
),
events=events,
placement=_placement_read(row) if row is not None else None,
)
# -- P4-B: resume upload + confirm (REQ-6-005, D-054, A-410) --------------------
@@ -19,7 +19,7 @@ import secrets
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from pydantic import BaseModel, Field
from ..config import Settings
@@ -37,6 +37,7 @@ from .deps import (
get_provider,
get_settings,
)
from .entitlement import consume_posting_entitlement
logger = logging.getLogger(__name__)
@@ -157,11 +158,30 @@ class AdminApplicationsResponse(BaseModel):
@router.post("/postings", status_code=201, response_model=AdminPostingResponse)
async def create_posting(
body: AdminPostingCreate,
request: Request,
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(require_admin),
) -> AdminPostingResponse:
"""Create a DRAFT posting (founder authoring path, D-050 admin_api
adapter — source=admin). Publish is a separate lifecycle call."""
adapter — source=admin). Publish is a separate lifecycle call.
v0.10 P4 (D-091, Task 4-1-01): the inline gate is extracted into the
shared api-layer helper `consume_posting_entitlement` (api/entitlement.
py) — this admin route and the employer create route (Task 4-1-02)
compose the SAME gate, never re-implemented copies. The admin
principal passes the admin-checkout pointer; the 402 dict (hardcoded
posts_used/posts_limit zeros, R-58) is byte-stable across both
gates — the wire contract UpgradeRequiredError consumes unchanged.
Final-review fix preserved (fail-closed): the store is fetched via
getattr(app.state); a wiring miss (None) makes the helper refuse
with 503 — never an ungated 201.
"""
consume_posting_entitlement(
getattr(request.app.state, "billing_store", None),
body.employer_id,
"/v1/billing/admin/checkout",
)
posting = Posting(
id=f"adm-{uuid.uuid4().hex[:16]}",
source="admin",
@@ -0,0 +1,330 @@
"""Operator lane for placement oversight (D-106, REQ-11-002).
`require_admin` IMPORTED from marketplace_admin (the billing_admin.py
import precedent — token namespaces cannot collide, D-090): one
generic remediation transition route + revoke + the R-80 re-hire +
the reporting/export reads (P3/P4 extend this router). No employer
session, no learner gate — a third principal class (D-097).
OPERATOR = "operator" route-level provenance (employers_admin.py:54 —
no per-operator accounts exist, D-094); every action is
ledger-recorded with actor_type="operator" (the D-083 manual-grant
lane) and answers in employer_audit_events via the P2 routes' own
appends (operator transitions record through the placement ledger).
"""
from __future__ import annotations
import json
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response
from ..config import Settings
from ..marketplace.store import MarketplaceStore
from ..placements.base import (
GUIDING_GOAL,
AdminTransitionRequest,
ApplicationTransitionResponse,
HireTransitionRequest,
PlacementOverview,
PlacementRead,
)
from ..placements.store import PlacementRow, PlacementStore
from .deps import get_marketplace_store, get_placement_store
from .marketplace_admin import require_admin
#: D-094: route-level operator provenance — no per-operator accounts.
OPERATOR = "operator"
router = APIRouter(prefix="/v1/placements/admin", tags=["placements-admin"])
# The admin-token gate composes on EVERY route handler via the
# Annotated[Settings, Depends(require_admin)] house pattern (the
# employers_admin idiom — fail-closed; token namespaces cannot collide
# with employer session tokens, D-090).
AdminGate = Annotated[Settings, Depends(require_admin)]
def _placement_read(row: PlacementRow) -> PlacementRead:
return PlacementRead(
id=row.id,
learner_id=row.learner_id,
employer_id=row.employer_id,
posting_id=row.posting_id,
application_id=row.application_id,
role_title=row.role_title,
start_date=date.fromisoformat(row.start_date),
status=row.status, # type: ignore[arg-type]
created_at=row.created_at or _now(),
decided_at=row.decided_at or _now(),
)
def _now():
from datetime import UTC, datetime
return datetime.now(UTC)
@router.post(
"/applications/{application_id}/transition",
response_model=ApplicationTransitionResponse,
)
async def admin_transition_application(
application_id: str,
body: AdminTransitionRequest,
_gate: AdminGate,
store: MarketplaceStore = Depends(get_marketplace_store),
placements: PlacementStore = Depends(get_placement_store),
) -> ApplicationTransitionResponse:
"""The generic operator remediation route (D-106): ANY matrix-legal
transition incl. withdrawn on a learner's behalf (remediation with
provenance, the D-083 lane). G-327: `to` is a CLOSED Literal —
unknown values die at pydantic (422), matrix-illegal known values
reach the store (409 InvalidTransition). role_title/start_date
are required iff to=hired (the honest 422 with required-fields
detail, D-103)."""
role_title: str | None = None
start_date: date | None = None
if body.to == "hired":
if body.role_title is None or body.start_date is None:
raise HTTPException(
status_code=422,
detail="role_title and start_date are required when to=hired (D-103)",
)
role_title, start_date = body.role_title, body.start_date
try:
application, placement = placements.transition_application(
application_id,
body.to,
actor_type="operator",
actor_ref=OPERATOR,
role_title=role_title,
start_date=start_date,
)
except Exception as exc:
# The placements InvalidTransition (409) MUST be matched before
# the ValueError arm — InvalidTransition subclasses ValueError.
from ..placements.base import InvalidTransition
if isinstance(exc, InvalidTransition):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, ValueError):
raise HTTPException(status_code=422, detail=str(exc)) from exc
raise
return ApplicationTransitionResponse(
application_id=application.id,
status=application.status, # type: ignore[arg-type]
placement=_placement_read(placement) if placement is not None else None,
)
@router.post("/placements/{placement_id}/revoke", response_model=PlacementRead)
async def admin_revoke_placement(
placement_id: str,
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> PlacementRead:
"""D-102: the operator correction — the one active → revoked
placement transition (operator-only by route). Unknown → 404;
non-active → 409 (the matrix)."""
try:
revoked = placements.revoke_placement(placement_id, OPERATOR)
except Exception as exc:
from ..placements.base import InvalidTransition
if isinstance(exc, InvalidTransition):
raise HTTPException(status_code=409, detail=str(exc)) from exc
raise
if revoked is None:
raise HTTPException(
status_code=404, detail=f"no placement with id {placement_id!r}"
)
return _placement_read(revoked)
@router.post(
"/placements/by-application/{application_id}/re-hire",
response_model=PlacementRead,
)
async def admin_rehire_placement(
application_id: str,
body: HireTransitionRequest,
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> PlacementRead:
"""THE R-80 RE-HIRE ROUTE: after the first hire the application is
TERMINAL (hired → hired is a 409 on every matrix-validated
surface — the coherence gap research found). The re-hire creates
the FRESH PlacementRow + the placement.rehired ledger event
WITHOUT touching the application's terminal status — the ledger
reads hired → revoke → rehire as three events, never fabricating
a second application transition. Structurally safe: revoked rows
never match the partial WHERE status='active' (the G-316
property). G-326: decided_at = the re-hire event's stamp."""
try:
rehired = placements.rehire_placement(
application_id,
role_title=body.role_title,
start_date=body.start_date,
actor_ref=OPERATOR,
)
except Exception as exc:
from sqlalchemy.exc import IntegrityError
from ..placements.base import InvalidTransition
if isinstance(exc, InvalidTransition):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, ValueError):
raise HTTPException(status_code=422, detail=str(exc)) from exc
if isinstance(exc, IntegrityError):
# the partial-unique arm: an ACTIVE placement already exists
raise HTTPException(
status_code=409,
detail="an active placement already exists for this "
"application — revoke it before re-hiring",
) from exc
raise
return _placement_read(rehired)
# -- the reporting read (v0.11 P3, Task 3-1-01 — REQ-11-003, D-107) ---------------
@router.get("/overview", response_model=PlacementOverview)
async def placement_overview(
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> PlacementOverview:
"""The D-107 computation — counts from ROWS at query time (never
denormalized counters, G-302/G-306): the guiding metric is DISTINCT
learners with ≥1 active placement, all-time cumulative toward
GUIDING_GOAL (a learner placed twice counts ONCE — the outcome's
noun is learners); revoked excluded from every count (D-102);
cohort month = the calendar month of start_date (YYYY-MM); orderings
pinned (R-73: by_employer count desc then employer_id;
by_cohort_month month ascending)."""
rows = placements.list_placements(status="active")
learner_ids = {row.learner_id for row in rows}
by_employer: dict[str, int] = {}
by_month: dict[str, int] = {}
for row in rows:
by_employer[row.employer_id] = by_employer.get(row.employer_id, 0) + 1
month = row.start_date[:7] # ISO YYYY-MM (the calendar slice)
by_month[month] = by_month.get(month, 0) + 1
return PlacementOverview(
total_active_placements=len(rows),
distinct_learners_placed=len(learner_ids),
guiding_goal=GUIDING_GOAL,
by_employer=[
{"employer_id": employer_id, "count": count}
for employer_id, count in sorted(
by_employer.items(), key=lambda kv: (-kv[1], kv[0])
)
],
by_cohort_month=[
{"month": month, "count": count}
for month, count in sorted(by_month.items())
],
)
# -- the audit export (v0.11 P4, Tasks 4-1-01/4-1-02 — REQ-11-004, D-108) ----------
@router.get("/export")
async def placement_export(
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> Response:
"""GET /v1/placements/admin/export — ONE self-contained JSON
document (D-108): {schema_version, generated_at, summary, cohort,
evidence_bundles (EVERY placement, active + revoked — G-325),
methodology (the G-321 statements + the three R-67 pilot gaps),
ledger (every event + head_hash)}. Determinism per R-73: pinned
orderings, no floats, generated_at excluded from comparison (the
re-export test strips it)."""
document = placements.build_export()
return Response(
content=json.dumps(document, sort_keys=True, separators=(",", ":")),
media_type="application/json",
)
@router.get("/export/summary")
async def placement_export_summary(
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> Response:
"""GET /v1/placements/admin/export/summary — text/markdown
rendered FROM the same JSON document (one builder, two
renderings — the anti-drift rule: the summary can never assert a
number the export doesn't contain). G-328: the title carries the
ATTESTATION language (employer-attested, operator-overseen) —
never a verification claim."""
document = placements.build_export()
summary = document["summary"]
methodology = document["methodology"]
head = document["ledger"]["head_hash"]
lines: list[str] = []
lines.append("# Placement Audit Export — Summary")
lines.append("")
lines.append(
"Employer-attested, operator-overseen placement records (NOT "
"third-party employment verification — see Methodology)."
)
lines.append("")
lines.append("## Totals")
lines.append("")
lines.append("| Metric | Count |")
lines.append("|---|---|")
lines.append(
f"| Distinct learners placed (toward {GUIDING_GOAL}) "
f"| {summary['distinct_learners_placed']} |"
)
lines.append(f"| Total active placements | {summary['total_active_placements']} |")
lines.append(f"| Revoked placements | {summary['revoked_count']} |")
lines.append("")
lines.append("## By cohort month (employment start)")
lines.append("")
lines.append("| Month | Placements |")
lines.append("|---|---|")
for bucket in summary["by_cohort_month"]:
lines.append(f"| {bucket['month']} | {bucket['count']} |")
if not summary["by_cohort_month"]:
lines.append("| (none recorded) | 0 |")
lines.append("")
lines.append("## Methodology")
lines.append("")
lines.append(f"### Placement definition\n\n{methodology['placement_definition']}\n")
lines.append(f"### Counting rules\n\n{methodology['counting_rules']}\n")
lines.append(f"### Verification\n\n{methodology['verification']}\n")
lines.append(f"### Scope\n\n{methodology['scope']}\n")
lines.append("## Verifying the export offline")
lines.append("")
lines.append(
"Every ledger event is embedded in the JSON export. Re-walk the "
"chain: for each event in id order, compute "
"`sha256(canonical_json + prev_hash)` where canonical_json is "
"the event's {id, application_id, placement_id, event_type, "
"actor_type, actor_ref, payload, created_at} serialized with "
"sorted keys and compact separators (created_at in UTC ISO-8601), "
"and prev_hash starts at the all-zeros GENESIS constant. The "
"final computed hash must equal head_hash:"
)
lines.append("")
lines.append(f"`head_hash: {head}`")
lines.append("")
lines.append(
"Then compare against a PRIOR export: its ledger.events must "
"appear as an exact prefix of this export's events, and its "
"head_hash must equal the hash at that prefix position "
"(export-to-export continuity is the anchor against wholesale "
"database replacement — see Verification)."
)
return Response(
content="\n".join(lines),
media_type="text/markdown",
)
@@ -30,6 +30,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, ConfigDict, Field
from ..config import Settings
from ..sandbox.capability import DEGRADE_REMEDIATION, sandbox_fabric_degraded
from ..sandbox.manager import (
PoolFullError,
SandboxHandleInfo,
@@ -154,6 +155,19 @@ async def create_sandbox(
)
_check_per_learner_cap(await manager.list(), body.learner_id, settings)
_check_global_create_rate(settings)
# v0.8 honest degrade (D-068/G-208): when the namespace fabric cannot
# work on this host (unprivileged container under docker's default
# seccomp), env creation is rejected BEFORE the manager is touched —
# same 503 class as PoolFullError, carrying the shared remediation
# constant (the boot notice logs the same text; they cannot drift).
# Structural backends (test stubs — no userns involved) declare
# `structural = True` and are exempt: the stub-backed suite must stay
# green inside job containers (D-063) while the REAL fabric degrades
# honestly everywhere.
manager_backend = getattr(manager, "_backend", None)
structural = getattr(manager_backend, "structural", False)
if not structural and sandbox_fabric_degraded():
raise HTTPException(status_code=503, detail=DEGRADE_REMEDIATION)
try:
info = await manager.create(body.learner_id, task_id=body.task_id)
except PoolFullError as exc:
@@ -0,0 +1,58 @@
"""Billing engine package (REQ-9-001, D-075..D-084, v0.9 Payment Engine).
The 7th domain module inside apps/ai-service (no new apps, no new ports —
the D-031 boundary): pydantic contracts + the provider-agnostic
`PaymentProvider` protocol (`base.py`), the shared HMAC webhook
verification core (`webhooks.py`, D-077), the deterministic mock provider
(`mock.py`, D-075 default), and the settings-driven factory
(`factory.py`). The D-027 store family's seventh SQLite store
(`store.py`), the seeded catalog (`catalog.py`), and the real Stripe
provider (`stripe.py`) land with their owning phases.
Boundary (D-031 family discipline): `billing/` never imports `agents/`
or `api/`; this package imports stdlib + pydantic + `config` only.
"""
from .base import (
CANONICAL_EVENT_NAMES,
CanonicalBillingEvent,
CanonicalEventName,
CheckoutMode,
CheckoutSession,
CheckoutSessionSpec,
CheckoutStatus,
Package,
PaymentProvider,
PaymentStatus,
Plan,
PlanInterval,
SubscriptionRef,
SubscriptionStatus,
UnknownPaymentProviderError,
VerifiedEvent,
WebhookSignatureError,
)
from .factory import payment_provider_from_settings
from .mock import MockPaymentProvider
__all__ = [
"CANONICAL_EVENT_NAMES",
"CanonicalBillingEvent",
"CanonicalEventName",
"CheckoutMode",
"CheckoutSession",
"CheckoutSessionSpec",
"CheckoutStatus",
"MockPaymentProvider",
"Package",
"PaymentProvider",
"PaymentStatus",
"Plan",
"PlanInterval",
"SubscriptionRef",
"SubscriptionStatus",
"UnknownPaymentProviderError",
"VerifiedEvent",
"WebhookSignatureError",
"payment_provider_from_settings",
]
+282
View File
@@ -0,0 +1,282 @@
"""Billing pydantic contracts + PaymentProvider protocol (REQ-9-001).
The wire + provider shapes for the payment engine. `Package`/`Plan` are
the catalog item shapes (D-078: seeded DB rows; P2 seeds them); the
`CheckoutSession*` pair is the provider session seam (R-33..R-36);
`CanonicalBillingEvent` is OUR event vocabulary — normalized at the
provider boundary, never taken raw from a vendor (D-083, R-43).
Money (D-081): integer cents end-to-end — `int` with `ge=0`, no floats
anywhere in billing, currency `usd` fixed. Stripe itself is cents-native
(R-48: `unit_amount: 1000` = $10.00).
Id honesty (the identity-mock unique-mint lesson, A-305): every id in
this module is an opaque handle minted UNIQUELY per creation call —
never derived from user input or a deterministic hash of it.
Boundary (D-031 family): `billing/` never imports `agents/` or `api/`;
stdlib + pydantic + config only.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, Protocol, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field
#: One-time posting packages vs recurring talent-access plans: one shape
#: family, one table family (D-078/D-080 — a one-time purchase is NOT a
#: degenerate subscription).
PlanInterval = Literal["month", "year"]
#: Stripe-compatible checkout modes (R-34): payment = one-time packages,
#: subscription = recurring plans. `setup` exists on the wire enum but no
#: catalog item maps to it in v0.9.
CheckoutMode = Literal["payment", "setup", "subscription"]
#: Session status vocabulary (R-36): open (awaiting payment) → complete
#: (checkout finished) | expired (window elapsed). The GRANT predicate is
#: `status == "complete" AND payment_status == "paid"` — never status
#: alone (R-37: async methods can leave complete with payment still
#: processing).
CheckoutStatus = Literal["open", "complete", "expired"]
#: Payment status vocabulary (R-36). `no_payment_required` exists on the
#: wire enum; v0.9 grants only on `paid`.
PaymentStatus = Literal["paid", "unpaid", "no_payment_required"]
#: Subscription lifecycle states (D-083). G-305: `upgraded` is NOT a
#: status — upgrade is a plan_id transition WITHIN `active`, recorded as
#: a billing_event with provenance. `expired` here is the terminal past-
#: due-grace outcome; the store owns the transition matrix (P5).
SubscriptionStatus = Literal[
"trialing", "active", "past_due", "cancelled", "expired"
]
#: OUR canonical event names (D-083, R-43: Stripe has no
#: `subscription.renewed`/`checkout.completed` — the provider boundary
#: maps vendor names onto these; provider event ids are stored + deduped
#: VERBATIM so dedupe stays truthful).
CanonicalEventName = Literal[
"checkout.completed",
"invoice.paid",
"invoice.payment_failed",
"subscription.cancelled",
"subscription.renewed",
]
#: Tuple mirror of the Literal (docs + tests import this; keeps the enum
#: and any exhaustive switch in one place).
CANONICAL_EVENT_NAMES: tuple[CanonicalEventName, ...] = (
"checkout.completed",
"invoice.paid",
"invoice.payment_failed",
"subscription.cancelled",
"subscription.renewed",
)
class WebhookSignatureError(ValueError):
"""Webhook signature verification failed (D-077).
Raised with a precise reason (missing header / no v1 schemes / stale
timestamp / signature mismatch). The webhook route maps this to 401 —
a payment surface never processes an unverifiable event.
"""
class UnknownPaymentProviderError(ValueError):
"""Raised for a provider name outside the contract, or a real
provider selected without its required configuration (R-53 — the
voice/embedding factory pattern).
main.py's lifespan CATCHES this and falls back to mock with a loud
G-11 log; direct callers (tests) see the actionable raise.
"""
class Package(BaseModel):
"""A one-time posting package catalog row (D-078/D-080).
`posts_limit` counts posting creations (consumed one per
create_posting, newest-non-expired-first — D-080); `duration_days`
bounds the purchase window from the buy time. `features` is the
pricing-page copy list (P2 pins the wire shape both sides).
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
name: str = Field(min_length=1)
#: D-081: integer cents, ge=0 — currency usd fixed; never a float.
price_cents: int = Field(ge=0)
posts_limit: int = Field(ge=1)
duration_days: int = Field(ge=1)
features: list[str] = Field(default_factory=list)
class Plan(BaseModel):
"""A recurring talent-access plan catalog row (D-078/D-080).
`interval` distinguishes the billing cadence; entitlement derives
from the subscription's `expires_at` extension on each renewal
(D-083 — idempotent per event dedupe).
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
name: str = Field(min_length=1)
#: D-081: integer cents, ge=0 — currency usd fixed; never a float.
price_cents: int = Field(ge=0)
interval: PlanInterval
features: list[str] = Field(default_factory=list)
class CheckoutSessionSpec(BaseModel):
"""What the operator asks a provider to create (R-33..R-35).
The checkout route resolves `item_id` against the catalog and
carries the SNAPSHOT forward (D-078: `price_cents` + `item_name`
copied at buy time — catalog edits never rewrite purchase
history; the provider turns them into `line_items[0][price_data]`,
R-35). `client_reference_id` is our reconciliation key (R-34 — the
local checkout_sessions row id). `metadata` rides the session both
directions (D-076: operator_ref + employer_id provenance). G-310:
`success_url`/`cancel_url` default from `public_base_url` — a
hosted checkout cannot be created without them (R-34).
"""
model_config = ConfigDict(frozen=True)
mode: CheckoutMode
item_id: str = Field(min_length=1)
#: D-078 snapshot: the catalog price at buy time (D-081 cents).
price_cents: int = Field(ge=0)
#: D-078 snapshot: the catalog name at buy time.
item_name: str = Field(min_length=1)
#: Recurring cadence for plans ("month"/"year"); empty for packages.
interval: str = ""
success_url: str = ""
cancel_url: str = ""
client_reference_id: str = Field(min_length=1, max_length=200)
metadata: dict[str, str] = Field(default_factory=dict)
class CheckoutSession(BaseModel):
"""Provider session state (R-36 normalized fields).
`url` is present only while a session is open (hosted checkout);
`expires_at` bounds the payment window (R-34: 30m–24h on Stripe).
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
url: str = ""
status: CheckoutStatus
payment_status: PaymentStatus
mode: CheckoutMode
expires_at: datetime | None = None
class SubscriptionRef(BaseModel):
"""A provider subscription handle (R-41/R-42 normalized states).
The store owns the lifecycle rows; this is what provider calls
(cancel/update) and webhook payloads exchange.
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
status: SubscriptionStatus
class CanonicalBillingEvent(BaseModel):
"""A provider event normalized onto OUR vocabulary (D-083, R-43).
`payload` is UNTRUSTED (G-302): webhook processing treats it as ID
references only — session/subscription state resolves from LOCAL
rows; purchase price/name come from the stored snapshot (D-078),
never from the event body.
"""
model_config = ConfigDict(frozen=True)
name: CanonicalEventName
payload: dict[str, Any] = Field(default_factory=dict)
class VerifiedEvent(BaseModel):
"""What verify_webhook returns: the authenticated event.
Verification authenticates the TRANSPORT (HMAC over the raw body);
`event` is the parsed body AFTER the signature gate. Providers that
need raw-body parsing keep it inside their own verify_webhook.
"""
model_config = ConfigDict(frozen=True)
event: CanonicalBillingEvent
#: Provider event id, stored + deduped VERBATIM (R-43/D-083 — never
#: normalized; the UniqueConstraint is on this exact string).
provider_event_id: str = ""
@runtime_checkable
class PaymentProvider(Protocol):
"""The payment port (D-075): checkout + subscriptions + webhooks.
Two implementations ship in-milestone: deterministic mock (default —
tests never call a real provider) and Stripe-compatible REST (P3,
exercised ONLY through httpx.MockTransport byte-contract tests).
Never imports agents/api; selected by `factory.py` from Settings.
"""
async def create_checkout_session(
self, spec: CheckoutSessionSpec
) -> CheckoutSession:
"""Create a provider checkout session for a catalog item."""
...
async def retrieve_session(self, session_id: str) -> CheckoutSession | None:
"""Fetch session state; None when the provider does not know the id."""
...
async def cancel_subscription(
self, subscription_id: str, *, at_period_end: bool = False
) -> SubscriptionRef | None:
"""Cancel a subscription (R-46: immediate DELETE or deferred
cancel_at_period_end); None when unknown to the provider."""
...
async def update_subscription(
self, subscription_id: str, new_price_cents: int
) -> SubscriptionRef | None:
"""Move a subscription to a new price (R-47: the provider resolves
the subscription_item id inside — items[0][id] must be in the
request or the new price is ADDED alongside the old → dual
charge)."""
...
def verify_webhook(
self,
raw_body: bytes,
signature_header: str,
*,
now: float | None = None,
) -> VerifiedEvent:
"""Authenticate + parse a webhook delivery (D-077).
`raw_body` must be the exact un-parsed bytes (risk-1); raises
`WebhookSignatureError` on any verification failure — the route
maps that to 401, never processes further.
"""
...
def normalize_event(self, provider_event: dict[str, Any]) -> CanonicalBillingEvent:
"""Map a provider-native event payload onto our canonical names
(R-43). Mock speaks canonical names directly → passthrough."""
...
@@ -0,0 +1,105 @@
"""Seeded billing catalog — the one source of truth for pricing (D-078).
The catalog mirrors the pricing page's tier structure (REQ-017 /
REQ-9-002): prices + names + feature copy live HERE and flow to the
page via `/v1/billing/catalog` — tier copy is data-driven, never
hardcoded on both sides (D-079's no-drift rule). Seeding is idempotent
(`SQLiteBillingStore.seed_catalog` upserts by id), so the app lifespan
can seed on every boot.
Enterprise tiers: the pricing page models them as "Custom" — the pilot
catalog keeps that honesty by seeding them as price_cents=0 contact
tiers (checkout never applies; an operator grants enterprise manually
per D-080's unlimited path). The features copy records the
"Contact Sales" semantics verbatim from the page.
Money (D-081): integer cents — 4900 = $49.00; currency `usd` fixed.
"""
from __future__ import annotations
from .base import Package, Plan
from .store import BillingStore
SEED_PACKAGES: list[Package] = [
Package(
id="pkg-single",
name="Single Post",
price_cents=4900,
posts_limit=1,
duration_days=30,
features=[
"1 job posting",
"30 days live",
"Basic analytics",
"Standard placement",
],
),
Package(
id="pkg-bundle",
name="Bundle",
price_cents=39900,
posts_limit=10,
duration_days=90,
features=[
"10 job postings",
"90 days each",
"Advanced analytics",
"Priority placement",
],
),
Package(
id="pkg-enterprise",
name="Enterprise",
price_cents=0,
posts_limit=1000,
duration_days=365,
features=[
"Unlimited postings",
"Dedicated account manager",
"API access",
"Custom branding",
"Custom pricing — contact sales (operator-granted at pilot)",
],
),
]
SEED_PLANS: list[Plan] = [
Plan(
id="plan-starter",
name="Starter",
price_cents=9900,
interval="month",
features=["Browse talent database", "10 candidate views/mo", "Basic filters"],
),
Plan(
id="plan-pro",
name="Pro",
price_cents=29900,
interval="month",
features=[
"Unlimited views",
"AI matching",
"Advanced filters",
"Saved searches",
],
),
Plan(
id="plan-enterprise",
name="Enterprise",
price_cents=0,
interval="year",
features=[
"Everything in Pro",
"API access",
"Bulk export",
"Dedicated CSM",
"Custom pricing — contact sales (operator-granted at pilot)",
],
),
]
def seed_billing_catalog(store: BillingStore) -> None:
"""Idempotent catalog seeding (D-078) — safe on every boot."""
store.seed_catalog(SEED_PACKAGES, SEED_PLANS)
@@ -0,0 +1,71 @@
"""Payment provider factory (D-075, R-53, REQ-9-001).
`AI_PAYMENT_PROVIDER = mock | stripe` (default: mock — the no-key path
is first-class; tests never call a real provider). Mirrors the
voice/embedding factory pattern: the factory raises
`UnknownPaymentProviderError` with an actionable message for direct
callers (tests), while the lifespan in main.py CATCHES it and falls
back to mock with a loud log — a typo'd env must never crash the
unattended boot (G-11).
P3: `stripe` is REAL (billing/stripe.py) — but needs its API key,
webhook secret, AND `public_base_url` (G-310: hosted checkout cannot
build success/cancel URLs without it); a missing piece raises the
actionable error the lifespan catches → G-11 mock fallback. The
http_client rides in from main.py's shared pool (R-33).
Webhook secret (D-077): the settings default is the empty string —
UNSET → the factory substitutes a per-boot random secret (the
telemetry_ingest_secret pattern). Restart invalidates it, so stale
signed deliveries fail: webhooks fail closed when unconfigured, which
is correct for a payment surface.
"""
from __future__ import annotations
import secrets
import httpx
from ..config import Settings
from .base import PaymentProvider, UnknownPaymentProviderError
from .mock import MockPaymentProvider
def resolve_webhook_secret(settings: Settings) -> str:
"""Configured `AI_WEBHOOK_SECRET` verbatim; unset → per-boot random
(fail-closed webhooks, D-077 — the telemetry_ingest_secret shape)."""
if settings.webhook_secret:
return settings.webhook_secret
return secrets.token_urlsafe(32)
def payment_provider_from_settings(
settings: Settings, http_client: httpx.AsyncClient | None = None
) -> PaymentProvider:
"""Select the payment provider by settings (env `AI_PAYMENT_PROVIDER`).
The selected provider gets the webhook secret resolved here
(D-077). Stripe needs the shared httpx pool — main.py passes its
app.state client; direct test callers construct their own.
"""
name = (settings.payment_provider or "mock").strip().lower()
webhook_secret = resolve_webhook_secret(settings)
if name == "mock":
return MockPaymentProvider(webhook_secret=webhook_secret)
if name == "stripe":
# R-53/G-310: config errors (key/secret/base_url) surface from
# the stripe factory's own gate — the operator-facing ones;
# the httpx pool is a caller concern checked after config.
from .stripe import stripe_provider_from_settings, validate_stripe_config
validate_stripe_config(settings)
if http_client is None:
raise UnknownPaymentProviderError(
"stripe provider needs the shared httpx pool — "
"pass http_client (main.py does; tests construct one)"
)
return stripe_provider_from_settings(settings, http_client)
raise UnknownPaymentProviderError(
f"unknown AI_PAYMENT_PROVIDER {name!r}: use 'mock' or 'stripe'"
)
+241
View File
@@ -0,0 +1,241 @@
"""Deterministic mock payment provider (D-075, REQ-9-001).
Stripe-shaped without any network: sessions (`cs_mock_...` ids), the
same HMAC webhook scheme (via webhooks.mint/verify — byte-identical to
the real provider path), and canonical event names spoken directly
(normalize_event is a passthrough — R-43's boundary maps VENDOR names;
the mock has none to map). Tests never call a real provider; the
mock-complete path (D-082, P3) mints a signed event through the REAL
webhook processing via `mint_event`.
Id honesty (the identity-mock unique-mint lesson, verified there):
session ids are minted UNIQUELY per create call — a monotonic counter
plus per-process seed from `secrets`. A deterministic id derived from
the spec would collide on any retry/re-entry of the same purchase
request and the store's per-session idempotency (G-303) would silently
conflate distinct logical checkouts. Uniqueness per call is the
contract; determinism of VERDICTS (what tests pin) is preserved — the
session lifecycle is scriptable, not the ids.
Session lifecycle: create → status open / payment unpaid; the operator
completes out-of-band payment via `confirm_completion` (the D-076
pilot flow — P3's admin-gated mock-complete route calls this) →
status complete / payment_status paid (the R-37 grant predicate).
"""
from __future__ import annotations
import itertools
import json
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from . import webhooks
from .base import (
CanonicalBillingEvent,
CheckoutSession,
CheckoutSessionSpec,
SubscriptionRef,
VerifiedEvent,
)
#: Session payment window (R-34's shape; Stripe allows 30m–24h — the
#: mock keeps a roomy 24h default so operator-mediated completion is
#: never raced in the pilot).
_SESSION_TTL = timedelta(hours=24)
#: Event-id prefix — provider event ids are stored + deduped VERBATIM
#: (R-43/D-083); the mock mints them uniquely like session ids.
_EVENT_ID_PREFIX = "evt_mock_"
class MockPaymentProvider:
"""In-memory, deterministic, zero-network PaymentProvider.
Scriptable sessions + real-scheme webhook minting — the full
checkout→webhook loop is provable without any vendor.
"""
def __init__(self, webhook_secret: str = "") -> None:
# Per-process nonce: ids are opaque handles, never derived from
# user input — counter + nonce keeps ids unique within and
# across provider instances on one box.
self._nonce = secrets.randbits(32)
self._session_counter = itertools.count()
self._event_counter = itertools.count()
# Sessions by id: the provider's view of checkout state.
self._sessions: dict[str, CheckoutSession] = {}
# Subscriptions by id: created on subscription-mode completion
# (P5 drives the lifecycle; the mock keeps the ref shape honest).
self._subscriptions: dict[str, SubscriptionRef] = {}
self._webhook_secret = webhook_secret or secrets.token_urlsafe(32)
# -- ids -----------------------------------------------------------------
def _mint_session_id(self) -> str:
# Stripe-shaped cs_ prefix + mock marker (R-36's id shape; the
# mock marker keeps logs/test failures self-explanatory).
return (
f"cs_mock_{self._nonce:08x}{next(self._session_counter):08x}"
)
def _mint_event_id(self) -> str:
return (
f"{_EVENT_ID_PREFIX}{self._nonce:08x}{next(self._event_counter):08x}"
)
# -- checkout ------------------------------------------------------------
async def create_checkout_session(
self, spec: CheckoutSessionSpec
) -> CheckoutSession:
"""Create a session (status open, payment unpaid — R-36)."""
session_id = self._mint_session_id()
session = CheckoutSession(
id=session_id,
# Hosted-checkout stand-in: the mock has no hosted page; the
# url is a stable pseudo-hosted pointer (never fetched).
url=f"https://mock.local/checkout/{session_id}",
status="open",
payment_status="unpaid",
mode=spec.mode,
expires_at=datetime.now(UTC) + _SESSION_TTL,
)
self._sessions[session.id] = session
return session
async def retrieve_session(self, session_id: str) -> CheckoutSession | None:
return self._sessions.get(session_id)
async def confirm_completion(self, session_id: str) -> CheckoutSession:
"""Flip a session to complete/paid (the R-37 grant predicate).
The out-of-band payment step of the pilot flow (D-076): P3's
admin-gated mock-complete route calls this, then mints the
signed webhook event through the real processing path. Unknown
or already-complete sessions raise ValueError (fail loud, not
idempotent-silent: the ROUTE owns idempotency via event dedupe).
"""
session = self._sessions.get(session_id)
if session is None:
raise ValueError(f"unknown checkout session {session_id!r}")
if session.status != "open":
raise ValueError(
f"session {session_id!r} is {session.status!r}, not open"
)
completed = session.model_copy(
update={"status": "complete", "payment_status": "paid"}
)
self._sessions[session_id] = completed
if completed.mode == "subscription":
# Subscription-mode completion creates the provider-side
# subscription ref (R-36: `subscription` present in
# subscription mode only).
self._subscriptions.setdefault(
f"sub_mock_{session_id.removeprefix('cs_')}",
SubscriptionRef(
id=f"sub_mock_{session_id.removeprefix('cs_')}",
status="active",
),
)
return completed
# -- subscriptions ---------------------------------------------------------
async def cancel_subscription(
self, subscription_id: str, *, at_period_end: bool = False
) -> SubscriptionRef | None:
subscription = self._subscriptions.get(subscription_id)
if subscription is None:
return None
# R-46 shape: immediate → cancelled now; deferred → stays active
# until period end (the P5 webhook drives the final state).
if at_period_end:
return subscription
cancelled = subscription.model_copy(update={"status": "cancelled"})
self._subscriptions[subscription_id] = cancelled
return cancelled
async def update_subscription(
self, subscription_id: str, new_price_cents: int
) -> SubscriptionRef | None:
subscription = self._subscriptions.get(subscription_id)
if subscription is None:
return None
# R-47 note: the real provider resolves items[0][id] inside; the
# mock has no line items to dual-charge — price is recorded by
# the store's plan snapshot (D-078), so this is a state no-op
# that keeps the protocol shape honest.
return subscription
# -- webhooks (D-077 scheme, shared core) -----------------------------------
def verify_webhook(
self,
raw_body: bytes,
signature_header: str,
*,
now: float | None = None,
) -> VerifiedEvent:
"""Authenticate + parse a mock delivery via the shared core."""
webhooks.verify(
raw_body, signature_header, self._webhook_secret, now=now
)
body = _parse_json_body(raw_body)
event = self.normalize_event(body)
# Provider event id: top-level on the body, kept VERBATIM (the
# R-43 rule — normalize the NAME, never the id).
provider_event_id = str(body.get("id", ""))
return VerifiedEvent(
event=event, provider_event_id=provider_event_id
)
def mint_event(
self, name: str, payload: dict[str, Any]
) -> tuple[bytes, str, str]:
"""Mint a signed webhook delivery for tests + the complete path.
Returns `(raw_body, signature_header, provider_event_id)`. The
body is the canonical event JSON — the exact bytes a provider
would POST and the webhook route would read via
`await request.body()`. Round-trip green through
verify_webhook by construction (mint/verify share the scheme).
"""
provider_event_id = self._mint_event_id()
body = json.dumps(
{
"id": provider_event_id,
"name": name,
"payload": payload,
}
).encode()
header = webhooks.mint(self._webhook_secret, body)
return body, header, provider_event_id
def normalize_event(
self, provider_event: dict[str, Any]
) -> CanonicalBillingEvent:
"""Passthrough (R-43): the mock speaks canonical names directly."""
name = str(provider_event.get("name", ""))
if name not in (
"checkout.completed",
"invoice.paid",
"invoice.payment_failed",
"subscription.cancelled",
"subscription.renewed",
):
raise ValueError(f"unknown event name {name!r}")
payload = provider_event.get("payload")
return CanonicalBillingEvent(
name=name, # type: ignore[arg-type] # validated in-contract above
payload=payload if isinstance(payload, dict) else {},
)
def _parse_json_body(raw_body: bytes) -> dict[str, Any]:
"""Parse the event JSON body (post-verification, risk-1 safe)."""
decoded = json.loads(raw_body.decode("utf-8"))
if not isinstance(decoded, dict):
raise ValueError("event body is not a JSON object")
return decoded
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,343 @@
"""StripePaymentProvider — Stripe-compatible REST via the shared httpx
pool (REQ-9-003, D-75/R-33..R-50).
Form-encoded bodies with bracket nesting (R-33: `line_items[0]
[price_data][currency]=usd` — NOT JSON), HTTP Basic auth, an
Idempotency-Key per logical operation (R-49: a double-submit
otherwise creates two sessions), and the webhook signature scheme
shared with the mock (webhooks.py — D-077 is byte-for-byte Stripe's
`t=...,v1=...` HMAC-SHA256 over `{t}.{raw_body}`).
Exercised ONLY through httpx.MockTransport byte-contract tests
(D-075) — tests never call the cloud. Event normalization maps
vendor names onto OUR canonical vocabulary (R-43); provider event ids
are stored + deduped VERBATIM.
G-310: `success_url`/`cancel_url` default from `public_base_url` —
without them a hosted checkout session cannot be created (R-34:
success_url is required).
"""
from __future__ import annotations
import base64
import json
import uuid
from typing import Any
import httpx
from ..config import Settings
from .base import (
CanonicalBillingEvent,
CanonicalEventName,
CheckoutSession,
CheckoutSessionSpec,
SubscriptionRef,
SubscriptionStatus,
UnknownPaymentProviderError,
VerifiedEvent,
WebhookSignatureError,
)
from .webhooks import verify as verify_webhook_signature
#: R-43: vendor event type → our canonical name. `subscription.renewed`
#: has no vendor equivalent — a renewal IS an invoice.paid on a
#: recurring subscription; the corroborating `customer.subscription.
#: updated` maps alongside it (recorded, not conflated).
_EVENT_NAME_MAP: dict[str, CanonicalEventName] = {
"checkout.session.completed": "checkout.completed",
"invoice.paid": "invoice.paid",
"invoice.payment_succeeded": "invoice.paid",
"invoice.payment_failed": "invoice.payment_failed",
"customer.subscription.deleted": "subscription.cancelled",
"customer.subscription.updated": "subscription.renewed",
}
#: R-42: vendor status spellings → OUR enum (Stripe writes `canceled`
#: with one L; our vocabulary is `cancelled`).
_STATUS_MAP: dict[str, SubscriptionStatus] = {
"trialing": "trialing",
"incomplete": "trialing",
"active": "active",
"past_due": "past_due",
"canceled": "cancelled",
"unpaid": "expired",
"incomplete_expired": "expired",
}
def _normalize_status(raw: Any, default: SubscriptionStatus) -> SubscriptionStatus:
return _STATUS_MAP.get(str(raw), default)
class StripePaymentProvider:
"""Stripe-compatible REST implementation of PaymentProvider."""
def __init__(
self,
*,
http_client: httpx.AsyncClient,
api_key: str,
webhook_secret: str,
base_url: str = "https://api.stripe.com",
) -> None:
self._http = http_client
self._api_key = api_key
self._webhook_secret = webhook_secret
self._base_url = base_url.rstrip("/")
# -- helpers ------------------------------------------------------------
def _auth_headers(self) -> dict[str, str]:
# R-33: HTTP Basic with the secret key as the username and an
# empty password (`-u sk_...:`).
basic = base64.b64encode(f"{self._api_key}:".encode()).decode()
return {"Authorization": f"Basic {basic}"}
def _raise_for_status(self, response: httpx.Response) -> None:
if response.status_code >= 400:
# A-305: sanitize — the api key must never appear in any
# raised exception (the embedding-test precedent).
detail = response.text
if self._api_key and self._api_key in detail:
detail = detail.replace(self._api_key, "[redacted]")
raise httpx.HTTPStatusError(
f"stripe {response.status_code}: {detail}",
request=response.request,
response=response,
)
async def _form_post(self, path: str, data: dict[str, Any]) -> dict[str, Any]:
headers = self._auth_headers()
# R-49: an idempotency key per logical POST — a network retry of
# session creation must not mint two sessions.
headers["Idempotency-Key"] = str(uuid.uuid4())
response = await self._http.post(
f"{self._base_url}{path}",
data=data,
headers=headers,
)
self._raise_for_status(response)
return response.json()
# -- checkout sessions (R-33..R-37) ---------------------------------------
async def create_checkout_session(
self, spec: CheckoutSessionSpec
) -> CheckoutSession:
# R-33/R-35: form-encoded with bracket nesting. One-time vs
# recurring differs only by price_data[recurring][interval]
# (R-35) — the snapshot price rides the wire (D-078/D-081).
data: dict[str, Any] = {
"mode": spec.mode,
"client_reference_id": spec.client_reference_id,
"line_items[0][quantity]": "1",
"line_items[0][price_data][currency]": "usd",
"line_items[0][price_data][unit_amount]": str(spec.price_cents),
"line_items[0][price_data][product_data][name]": spec.item_name,
}
if spec.interval:
data["line_items[0][price_data][recurring][interval]"] = spec.interval
for key, value in spec.metadata.items():
data[f"metadata[{key}]"] = value
# G-310: hosted checkout requires success_url (R-34) — the
# checkout route defaults these from public_base_url.
data["success_url"] = spec.success_url
data["cancel_url"] = spec.cancel_url
body = await self._form_post("/v1/checkout/sessions", data)
return self._session_from_api(body)
async def retrieve_session(self, session_id: str) -> CheckoutSession | None:
try:
body = await self._get(f"/v1/checkout/sessions/{session_id}")
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
return self._session_from_api(body)
async def _get(self, path: str) -> dict[str, Any]:
response = await self._http.get(
f"{self._base_url}{path}",
headers=self._auth_headers(),
)
self._raise_for_status(response)
return response.json()
@staticmethod
def _session_from_api(body: dict[str, Any]) -> CheckoutSession:
# Final-review fix: coerce + default every enum-adjacent value —
# a vendor body with an unexpected status spelling (or a missing
# one) must not crash the route with a pydantic ValidationError
# (the G-306 never-500 discipline).
status = str(body.get("status", "open"))
if status not in ("open", "complete", "expired"):
status = "open"
payment_status = str(body.get("payment_status", "unpaid"))
if payment_status not in ("paid", "unpaid", "no_payment_required"):
payment_status = "unpaid"
mode = str(body.get("mode", "payment"))
if mode not in ("payment", "setup", "subscription"):
mode = "payment"
return CheckoutSession(
id=str(body.get("id", "")),
url=str(body.get("url") or ""),
status=status, # type: ignore[arg-type]
payment_status=payment_status, # type: ignore[arg-type]
mode=mode, # type: ignore[arg-type]
expires_at=None,
)
# -- subscriptions (R-46/R-47) --------------------------------------------
async def cancel_subscription(
self, subscription_id: str, *, at_period_end: bool = False
) -> SubscriptionRef | None:
try:
if at_period_end:
body = await self._form_post(
f"/v1/subscriptions/{subscription_id}",
{"cancel_at_period_end": "true"},
)
else:
response = await self._http.delete(
f"{self._base_url}/v1/subscriptions/{subscription_id}",
headers=self._auth_headers(),
)
self._raise_for_status(response)
body = response.json()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
return SubscriptionRef(
id=str(body.get("id", subscription_id)),
status=_normalize_status(body.get("status", "cancelled"), "cancelled"),
)
async def update_subscription(
self, subscription_id: str, new_price_cents: int
) -> SubscriptionRef | None:
"""R-47: the provider resolves the subscription_item id INSIDE —
`items[0][id]` must ride the update or the new price is ADDED
alongside the old (dual-charge bug, pinned by byte-contract)."""
try:
current = await self._get(f"/v1/subscriptions/{subscription_id}")
items = current.get("items", {}).get("data", [])
if not items:
return None
item_id = str(items[0]["id"])
body = await self._form_post(
f"/v1/subscriptions/{subscription_id}",
{
"items[0][id]": item_id,
"items[0][price_data][currency]": "usd",
"items[0][price_data][unit_amount]": str(new_price_cents),
"items[0][price_data][product_data][name]": "nextcraft plan",
"items[0][price_data][recurring][interval]": "month",
},
)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
return SubscriptionRef(
id=str(body.get("id", subscription_id)),
status=_normalize_status(body.get("status", "active"), "active"),
)
# -- webhooks (D-077 — the shared scheme) ---------------------------------
def verify_webhook(
self,
raw_body: bytes,
signature_header: str,
*,
now: float | None = None,
) -> VerifiedEvent:
verify_webhook_signature(
raw_body,
signature_header,
self._webhook_secret,
now=now,
)
payload = _parse_event_payload(raw_body)
return VerifiedEvent(
event=self.normalize_event(payload),
provider_event_id=str(payload.get("id", "")),
)
def normalize_event(
self, provider_event: dict[str, Any]
) -> CanonicalBillingEvent:
"""R-43: map the vendor event onto OUR canonical vocabulary —
BOTH the name and the payload keys. Final-review fix: the
vendor object's key spellings (`id`, `subscription`) never
become the canonical keys (`session_id`, `subscription_id`)
the processing route reads, so under real Stripe every
checkout/renewal resolved no local row and landed unmatched
with NO entitlement — the mock speaks canonical keys directly
and hid the desync."""
vendor_type = str(provider_event.get("type", ""))
mapped = _EVENT_NAME_MAP.get(vendor_type)
if mapped is None:
raise UnknownPaymentProviderError(
f"unmapped provider event type {vendor_type!r}"
)
payload = dict(provider_event.get("data", {}).get("object", {}))
canonical: dict[str, Any] = dict(payload)
if vendor_type == "checkout.session.completed":
canonical["session_id"] = str(payload.get("id", ""))
else:
# invoice.* + customer.subscription.*: Stripe carries the
# subscription ref at data.object.subscription (and, on
# subscription objects, at data.object.id).
subscription_ref = payload.get("subscription") or payload.get("id")
canonical["subscription_id"] = str(subscription_ref or "")
return CanonicalBillingEvent(name=mapped, payload=canonical)
def _parse_event_payload(raw_body: bytes) -> dict[str, Any]:
try:
parsed = json.loads(raw_body.decode("utf-8"))
except (UnicodeDecodeError, ValueError) as exc:
raise WebhookSignatureError(f"event body is not JSON: {exc}") from exc
if not isinstance(parsed, dict):
raise WebhookSignatureError("event body is not a JSON object")
return parsed
def validate_stripe_config(settings: Settings) -> None:
"""R-53/G-310: the actionable config gate — main.py's lifespan
catches and falls back to mock with the loud G-11 log."""
api_key = settings.payment_api_key.get_secret_value()
if not api_key:
raise UnknownPaymentProviderError(
"AI_PAYMENT_PROVIDER=stripe requires AI_PAYMENT_API_KEY "
"(env-only, D-014) — set it or drop back to the mock default"
)
if not settings.webhook_secret:
raise UnknownPaymentProviderError(
"AI_PAYMENT_PROVIDER=stripe requires AI_WEBHOOK_SECRET — "
"webhooks fail closed without it (D-077)"
)
if not settings.public_base_url:
raise UnknownPaymentProviderError(
"AI_PAYMENT_PROVIDER=stripe requires AI_PUBLIC_BASE_URL — "
"hosted checkout cannot build success/cancel URLs without it (G-310)"
)
def stripe_provider_from_settings(
settings: Settings, http_client: httpx.AsyncClient
) -> StripePaymentProvider:
"""Factory wiring (R-53): validates config, then constructs the
provider over the shared httpx pool."""
validate_stripe_config(settings)
return StripePaymentProvider(
http_client=http_client,
api_key=settings.payment_api_key.get_secret_value(),
webhook_secret=settings.webhook_secret,
)
@@ -0,0 +1,142 @@
"""Webhook signature verification core (D-077, R-38..R-40).
Byte-for-byte Stripe's scheme shape, shared by every provider so the
mock and the real Stripe path verify identically (the byte-contract
lesson): header `Stripe-Signature: t=<unix_seconds>,v1=<hex>` on one
line; signed payload `"{timestamp}.{raw_body}"`; HMAC-SHA256 with the
endpoint secret; constant-time comparison; ±300 s replay window.
Security properties (each pinned by tests/billing/test_webhooks.py):
- raw body: verification needs the exact UN-PARSED bytes (risk-1) — the
caller passes `raw_body: bytes`, never a pre-parsed model;
- multiple `v1` values (secret rolls — R-38): verify iterates ALL v1
candidates, any match wins (R-53 risk-5);
- non-`v1` schemes (`v0` on Stripe test events, unknown future ones):
ignored entirely — a v0-only header has no v1 to check and fails
(downgrade protection);
- replay window (R-40, Stripe default 300 s): the timestamp bounds how
long a captured delivery can be replayed; `now` is injectable so
tests are deterministic without sleeps (risk-4);
- fail closed (D-077): every failure raises `WebhookSignatureError`
with a precise reason — a payment surface never processes an
unverifiable event.
`mint` exists for tests + the mock provider: it produces exactly what a
signed provider delivery looks like, so the mock-complete path (D-082)
can post through the REAL webhook processing.
"""
from __future__ import annotations
import hashlib
import hmac
import time
from .base import WebhookSignatureError
#: Replay tolerance in seconds (R-40: Stripe's official default; "don't
#: use a tolerance of 0" — clock skew between provider and box is real).
DEFAULT_TOLERANCE_S: float = 300.0
def _parse_header(header: str) -> tuple[int | None, list[str]]:
"""Parse `t=...,v1=...,v1=...` into (timestamp, v1 candidates).
Elements are comma-separated `key=value` pairs (R-38). Unknown
schemes (v0, anything else) are ignored: only `v1` is ever checked.
Returns timestamp None when the header carries no valid `t` pair.
"""
timestamp: int | None = None
v1_candidates: list[str] = []
for element in header.split(","):
key, sep, value = element.strip().partition("=")
if not sep:
continue
if key == "t":
try:
timestamp = int(value)
except ValueError:
# Malformed t → treat as absent; verify() fails on the
# stale/missing-timestamp branch (fail closed).
continue
elif key == "v1":
v1_candidates.append(value)
return timestamp, v1_candidates
def _compute_signature(
secret: str, timestamp: int, raw_body: bytes
) -> str:
"""HMAC-SHA256 over `"{timestamp}.{raw_body}"` (R-39).
The timestamp is attacker-immutable in transit: changing `t` without
the secret invalidates every v1 in the header.
"""
signed_payload = f"{timestamp}.".encode() + raw_body
return hmac.new(
secret.encode("utf-8"), signed_payload, hashlib.sha256
).hexdigest()
def mint(
secret: str,
raw_body: bytes,
now: float | None = None,
) -> str:
"""Mint a `Stripe-Signature` header for `raw_body` (D-077 scheme).
Test + mock-provider helper (the mint half of the telemetry
tokens.py precedent). `now` defaults to the wall clock; inject for
determinism. Produces exactly one `t` + one `v1` pair — what a
provider delivery carries.
"""
timestamp = int(now if now is not None else time.time())
signature = _compute_signature(secret, timestamp, raw_body)
return f"t={timestamp},v1={signature}"
def verify(
raw_body: bytes,
header: str | None,
secret: str,
*,
now: float | None = None,
tolerance_s: float = DEFAULT_TOLERANCE_S,
) -> tuple[int, list[str]]:
"""Verify a `Stripe-Signature` header against the raw body.
Returns `(timestamp, v1_candidates)` on success — the parsed header
the caller may want for replay bookkeeping. Raises
`WebhookSignatureError` with a precise reason otherwise:
- missing/empty header → "missing Stripe-Signature header";
- no `v1` schemes present → "no v1 signature schemes in header"
(a v0-only header lands here — non-v1 schemes are ignored);
- timestamp absent or outside ±tolerance_s of now → "stale
timestamp" (replay window, R-40);
- no v1 candidate matches → "signature mismatch" (constant-time
compare per candidate — any match wins across secret rolls).
"""
if not header:
raise WebhookSignatureError("missing Stripe-Signature header")
timestamp, v1_candidates = _parse_header(header)
if not v1_candidates:
raise WebhookSignatureError(
f"no v1 signature schemes in header {header!r} "
"(non-v1 schemes are ignored)"
)
if timestamp is None:
raise WebhookSignatureError(
f"missing or malformed timestamp in header {header!r}"
)
current = now if now is not None else time.time()
if abs(current - timestamp) > tolerance_s:
raise WebhookSignatureError(
f"stale timestamp {timestamp}: outside the "
f"{tolerance_s:g}s replay window at {current:g}"
)
for candidate in v1_candidates:
expected = _compute_signature(secret, timestamp, raw_body)
if hmac.compare_digest(expected, candidate):
return timestamp, v1_candidates
raise WebhookSignatureError("signature mismatch: no v1 candidate matches")
+31
View File
@@ -164,6 +164,16 @@ class Settings(BaseSettings):
# (a loud boot warning names the fix, G-11).
admin_token: SecretStr = SecretStr("")
# v0.10 employer auth (REQ-10-001, D-088): both throttle knobs —
# plain ints, the identity_submits_per_min shape (R-60d). Keyed by
# SUBMITTED EMAIL, never client IP (R-63); in-memory, per-process.
# (a) Failed logins per email per rolling 900 s window → 429 with
# the honest "wait" detail; a successful login clears it.
# (b) Well-formed login+register attempts per rolling 60 s window
# across all emails → 429 (G-314: failed passwords count).
employer_login_failures_per_window: int = 5
employer_auth_attempts_per_min: int = 30
# v0.6 P5 placeholder (D-056): the deployed origin for sitemap.xml /
# robots.txt absolute URLs (consumed by the SEO phase; documented
# now so operators see it beside the admin token).
@@ -213,3 +223,24 @@ class Settings(BaseSettings):
# A-302/D-041: upload guard before the provider call (webm/opus is
# ~0.5-1MB/min, so 10MB tolerates very long answers).
voice_max_audio_mb: int = 10
# v0.9 Payment Engine (REQ-9-001, D-075, R-53): payment provider
# selection — 'mock' (default: deterministic, zero-network; tests
# NEVER call a real payment API) or 'stripe' (real Stripe-compatible
# REST, P3). A typo'd name raises at the factory; main.py's lifespan
# catches it and falls back to mock with a loud log (G-11 — the
# voice/embedding factory pattern).
payment_provider: str = "mock"
# Real payment API key (Stripe sk_... class) — 'stripe' requires
# this; 'mock' ignores it. Keys env-only (D-014), never committed,
# never logged (SecretStr mirrors the admin token; tests use fixed
# test secrets, never real ones).
payment_api_key: SecretStr = SecretStr("")
# Webhook HMAC secret (D-077, the telemetry_ingest_secret pattern):
# UNSET (empty default) → the factory substitutes a PER-BOOT RANDOM
# secret — restart invalidates stale signed deliveries, so webhooks
# FAIL CLOSED when unconfigured (401, never process unverifiable —
# correct for a payment surface). Set for multi-process deploys.
webhook_secret: str = ""
@@ -0,0 +1,58 @@
"""Employer B2B auth module (REQ-10-001, D-086..D-097, v0.10).
The 8th domain module inside apps/ai-service (no new apps, no new ports,
no new dependencies — the D-031 boundary): pydantic contracts
(`base.py`), the D-027 store family's eighth SQLite store
(`store.py`, R-57/D-095), and the stdlib scrypt KDF (`passwords.py`,
D-086/R-55). Claim evidence (`claim_evidence.py`) and the auth routes
(`api/employers.py` via DI) land with their owning phases. No
`service.py` anywhere (R-57) — routes compose the store via api/ DI.
G-315: email fields are plain `str` with stdlib-only validation —
pydantic `EmailStr` is BANNED (email-validator would be the first new
dependency, breaking R-66/D-086).
D-097: employer accounts are UNVERIFIED — no learner gate
(require_verified_age/allowlist) is ever composed on an employer route.
Boundary (D-031 family discipline): `employers/` never imports `agents/`
or `api/`; this package imports stdlib + pydantic + `config` only.
"""
from .base import (
CLAIM_STATUSES,
OWN_POSTING_STATUSES,
ClaimEmployerRequest,
ClaimStatus,
EmployerAccount,
EmployerClaim,
EmployerMe,
EmployerPostingCreate,
EmployerPostingEdit,
EmployerPostingWriteResponse,
LoginEmployerRequest,
OwnPostingsFacets,
OwnPostingsResponse,
OwnPostingSummary,
RegisterEmployerRequest,
TokenResponse,
)
__all__ = [
"CLAIM_STATUSES",
"ClaimEmployerRequest",
"ClaimStatus",
"EmployerAccount",
"EmployerClaim",
"EmployerMe",
"EmployerPostingCreate",
"EmployerPostingEdit",
"EmployerPostingWriteResponse",
"LoginEmployerRequest",
"OWN_POSTING_STATUSES",
"OwnPostingSummary",
"OwnPostingsFacets",
"OwnPostingsResponse",
"RegisterEmployerRequest",
"TokenResponse",
]
@@ -0,0 +1,343 @@
"""Employer auth pydantic contracts (REQ-10-001, D-086..D-097).
The wire + domain shapes for the employer module. `EmployerAccount` /
`EmployerClaim` are the domain rows served over the wire; the request
pair carries G-315 email discipline + D-086 password min length;
`TokenResponse` / `EmployerMe` are the auth route responses.
G-315: email fields are plain `str` with stdlib-only validation —
non-empty, exactly one "@", non-empty domain, stripped + lowercased on
normalize. pydantic `EmailStr` is BANNED (email-validator is not in the
venv and adding it would be the first new dependency, breaking
R-66/D-086).
D-097: `EmployerAccount.email_verified` is `Literal[False]` — the
account is UNVERIFIED, displayed as such; never presented as verified.
Id honesty (A-305, the family rule): every id in this module is an
opaque handle minted UNIQUELY per creation call — never derived from
user input or a deterministic hash of it.
P3 (REQ-10-003, G-319): `OwnPostingSummary` REUSES the PostingSummary
wire shape by subclassing marketplace.base.Posting (the PostingSummary
serialization contract — never duplicated) + `applicant_count`. The PII
boundary is binding: applicant COUNTS are the only applications-derived
datum — learner identifiers never ride any employer-facing response.
Boundary (D-031 family): `employers/` never imports `agents/` or `api/`;
stdlib + pydantic + the marketplace WIRE contracts (G-319's PostingSummary
reuse — a pure-pydantic module, never a store/engine import) only.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..marketplace.base import Posting
#: Claim lifecycle states (D-089): every claim starts pending; an
#: operator approve/reject decides it. Approved activates ownership
#: (one account : one entity); rejection frees the entity for a new
#: claim. Deferred to P2's claim routes — the shape lands now.
ClaimStatus = Literal["pending", "approved", "rejected"]
#: Tuple mirror of the Literal (docs + tests import this; keeps the enum
#: and any exhaustive switch in one place).
CLAIM_STATUSES: tuple[ClaimStatus, ...] = (
"pending",
"approved",
"rejected",
)
#: G-315: password minimum length (D-086 honest validation floor).
PASSWORD_MIN_LENGTH = 8
def validate_email(value: str) -> str:
"""G-315: stdlib-only email validation + normalization.
Non-empty (after strip), exactly one "@", non-empty local part and
non-empty domain — normalized to stripped + lowercased. Deliberately
NOT RFC-5322: the honest floor for an unverified pilot signup is
structural (exactly one "@", a domain after it), never a regex
masquerade. pydantic `EmailStr` is BANNED (it would require
email-validator — the first new dependency, breaking R-66/D-086).
"""
normalized = value.strip().lower()
if not normalized:
raise ValueError("email must be non-empty")
if normalized.count("@") != 1:
raise ValueError('email must contain exactly one "@"')
local, _, domain = normalized.partition("@")
if not local:
raise ValueError("email local part must be non-empty")
if not domain:
raise ValueError("email domain must be non-empty")
return normalized
class EmployerAccount(BaseModel):
"""An employer-side login account (D-087).
`employer_id` is nullable-until-claim-approval (the UNIQUE
one-account-one-entity constraint activates on approve, P2). The
account is UNVERIFIED (D-097): `email_verified` is `Literal[False]`
— no email verification exists in v0.10, so the field can never
carry `True`, and no learner gate is ever composed on an employer
route.
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
#: G-315: plain str, stdlib-validated + normalized.
email: str = Field(min_length=3)
#: None until a claim is approved (P2).
employer_id: str | None = None
created_at: datetime
#: D-097: UNVERIFIED — displayed as such, never as verified.
email_verified: Literal[False] = False
class EmployerClaim(BaseModel):
"""An account's claim on an EmployerRow entity (D-089).
Advisory evidence is computed at claim time (P2's claim_evidence.py)
and stored as provenance JSON; the operator decision activates
ownership. Auto-approval is deferred (D-089: without email
verification the match is forgeable).
"""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
account_id: str = Field(min_length=1)
employer_id: str = Field(min_length=1)
status: ClaimStatus
evidence: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
#: Operator provenance (decided_by/decided_at) — None while pending.
decided_by: str | None = None
decided_at: datetime | None = None
class RegisterEmployerRequest(BaseModel):
"""POST /v1/employers/auth/register body (Task 1-2-01).
Honest validation (G-315 + D-086): stdlib email normalization; the
password carries a min-length-8 floor. Neither field is ever
echoed back by a 422 (the pii_fields scrub lands with the routes).
"""
email: str = Field(min_length=3)
password: str = Field(min_length=PASSWORD_MIN_LENGTH)
@field_validator("email")
@classmethod
def _validate_email(cls, value: str) -> str:
return validate_email(value)
class LoginEmployerRequest(RegisterEmployerRequest):
"""POST /v1/employers/auth/login body — same honest validation
(G-315 email + password floor); subclass so both routes share the
contract and drift is structurally impossible."""
class ClaimEmployerRequest(BaseModel):
"""POST /v1/employers/claims body (Task 2-1-03, D-089): the
marketplace EmployerRow id being claimed. The entity's existence
is checked against the marketplace store at the route (404
unknown); evidence is computed from the session account's email +
the entity's website AT CLAIM TIME (R-65)."""
employer_id: str = Field(min_length=1)
class TokenResponse(BaseModel):
"""Login/register session response (D-087): the bearer token (raw,
exactly once on this response — only sha256(token) is stored) and
its expiry (login + SESSION_TTL_DAYS, evaluated lazily at read)."""
model_config = ConfigDict(frozen=True)
token: str = Field(min_length=1)
expires_at: datetime
class EmployerMe(BaseModel):
"""GET /v1/employers/auth/me response (Task 1-2-01): the account
(unverified email displayed as such, D-097) + the claimed entity +
the claim state — None until a claim exists (P2)."""
model_config = ConfigDict(frozen=True)
account: EmployerAccount
#: None until a claim is approved (P2's decide_claim sets it).
claimed_employer_id: str | None = None
#: None until a claim is filed; the claim state (pending/approved/
#: rejected) once one exists (P2).
claim: EmployerClaim | None = None
#: Own-postings status facets (D-092/G-319): the posting lifecycle set
#: (marketplace.base.PostingStatus) — every facet key is one of these.
OWN_POSTING_STATUSES: tuple[str, ...] = ("draft", "published", "closed")
class OwnPostingSummary(Posting):
"""G-319: the employer-facing posting summary (Task 3-1-01) —
the PostingSummary wire shape REUSED (subclass of marketplace.base.
Posting — one serialization contract, never duplicated) + the
per-posting applicant COUNT (the D-092 overview data source, read
through the batched count_applications_by_posting — never a
per-posting N+1).
PII boundary (binding): applicant_count is the ONLY applications-
derived datum on this contract — learner identifiers never ride
any /v1/employers/* response."""
model_config = ConfigDict(frozen=True)
applicant_count: int = 0
class OwnPostingsFacets(BaseModel):
"""Status facet counts over the session employer's OWN postings
(draft|published|closed — the full lifecycle, unlike the public
board's published-only view). Every status key is ALWAYS present
(a status with zero postings carries its honest 0)."""
model_config = ConfigDict(frozen=True)
draft: int = 0
published: int = 0
closed: int = 0
class OwnPostingsResponse(BaseModel):
"""GET /v1/employers/postings response (Task 3-1-01, G-319): the
claimed employer's OWN postings (all lifecycle statuses, posted_at
DESC then id — the list_postings order) + per-status facet counts
+ the honest total. Unclaimed accounts never see this shape (the
route's 403 claim-CTA precedes it)."""
model_config = ConfigDict(frozen=True)
postings: list[OwnPostingSummary] = Field(default_factory=list)
facets: OwnPostingsFacets
total: int = 0
class EmployerPostingCreate(BaseModel):
"""POST /v1/employers/postings body (Task 4-1-02, REQ-10-004): the
employer-authored draft (the AdminPostingCreate shape minus
employer_id — the entity derives from the session's CLAIM, never
the body; G-317 stamps source="employer" at the route). Starts as
a DRAFT; publish is an explicit call (the admin-route lifecycle
discipline)."""
model_config = ConfigDict(frozen=True)
title: str = Field(min_length=1)
description: str = ""
required_competencies: list[str] = Field(default_factory=list)
skills: list[str] = Field(default_factory=list)
seniority: str | None = None
category: str | None = None
location: str = ""
remote: bool = False
salary_min: int | None = None
salary_max: int | None = None
class EmployerPostingEdit(BaseModel):
"""PATCH /v1/employers/postings/{id} body (Task 4-1-02, G-318): the
AdminPostingEdit shape — partial content edit, fields omitted stay
put; a successful edit RE-TAGS the posting (tags are derived data,
D-052 — the tagger overwrites). Own DRAFTS only."""
model_config = ConfigDict(frozen=True)
title: str | None = Field(default=None, min_length=1)
description: str | None = None
skills: list[str] | None = None
class EmployerPostingWriteResponse(BaseModel):
"""Create/edit/publish/close response (Task 4-1-02): the
AdminPostingResponse shape (G-317's source stamp rides it —
"employer" on every employer-authored row; seeded/admin rows keep
their own)."""
model_config = ConfigDict(frozen=True)
id: str
source: str
external_id: str
employer_id: str
title: str
status: str
# P5 (REQ-10-005, Task 5-1-01): the checkout/entitlement contracts
# REUSE the v0.9 billing shapes where they coincide (never duplicated —
# CheckoutResponse here is only the wire handle; the session spec +
# provider session live in billing/base.py as the ONE provider seam).
class EmployerCheckoutCreate(BaseModel):
"""POST /v1/employers/billing/checkout body (Task 5-1-01): which
catalog item — the buyer derives from the session's CLAIMED entity
(D-076's operator-mediated employer_id body field is GONE; the
claimed entity is the only buyer the route will ever record)."""
item_id: str = Field(min_length=1)
class EmployerCheckoutResponse(BaseModel):
"""The created session handle + the local reconciliation id (the
v0.9 billing_admin.CheckoutResponse shape REUSED verbatim — one
wire contract for both checkout principals, the D-091 doctrine
applied to the response rather than restated)."""
session_id: str
provider_session_id: str
url: str
mode: str
class EmployerEntitlementResponse(BaseModel):
"""GET /v1/employers/billing/entitlement response (Task 5-1-01):
the session employer's OWN entitlement summary — the v0.9
EntitlementResponse shape (REQ-9-004) resolved for the CLAIMED
entity only: active package (posts used/limit/expiry — lazy
past_due grace per G-306) + subscription state. Honest states:
purchase/subscription are None until a real grant exists; the
shape cannot distinguish never-purchased from expired (both read
None — the store skips expired windows lazily), so surfaces never
assert expiry provenance from a None purchase."""
employer_id: str
purchase: dict[str, Any] | None = None
subscription: dict[str, Any] | None = None
class EmployerMockCompleteRequest(BaseModel):
"""POST /v1/employers/billing/mock/complete body (Task 5-1-02,
D-082/G-301): the provider session id being completed. The id is
an UNTRUSTED reference (G-302) — the route resolves it against
the session's OWN checkout rows, never takes it on faith."""
provider_session_id: str = Field(min_length=1)
class EmployerMockCompleteResponse(BaseModel):
"""The completion result (the v0.9 MockCompleteResponse shape
REUSED verbatim)."""
completed: bool
payment_status: str
@@ -0,0 +1,115 @@
"""Advisory claim evidence — stdlib registrable-domain normalization (R-65, D-089).
The signal this module computes is ADVISORY and never displayed as
verification (A-304): the operator decision activates a claim, the
domain match merely informs it. Auto-approval is deferred (D-089) —
without email verification the match is forgeable.
R-65 normalization is stdlib-only (zero-dep discipline, R-66): no PSL
library, no tldextract. `urllib.parse.urlsplit(website).hostname`
lowercases and strips scheme/path ("https://example.com/openai" →
"example.com"); the email domain is taken after the last "@" and
lowercased (never the local part — it rides nowhere in the evidence).
The comparison is a simple SUFFIX match: two domains match when equal
or when one endswith "." + the other. KNOWN LIMIT (documented per the
task, accepted for an advisory signal): a multi-label public suffix
over-matches — "mail.co.uk" vs "co.uk" counts as a match even though
"co.uk" is a public suffix, not a registrable domain, because a
subdomain of a registrable domain is the intended match
("shop.acme.com" vs "acme.com") and suffix equality cannot tell the
two cases apart. That over-match is ACCEPTABLE HERE: the seeded
corpus's websites are example.com placeholders (match=false
corpus-wide at pilot, R-65) and the signal never gates anything — it
is recorded provenance an operator reads. A PSL would be the first
new dependency (R-66) — rejected.
Evidence shape per D-089: {account_email_domain, entity_website_domain,
match} — computed AT CLAIM TIME (Task 2-1-03) and stored as provenance
JSON on the claim row; it is a snapshot, never recomputed on read.
Boundary (D-031 family): stdlib + pydantic only; this module never
imports agents/ or api/ — it is a pure function family over strings.
"""
from __future__ import annotations
from urllib.parse import urlsplit
def normalize_email_domain(email: str) -> str:
"""The email's domain after the last "@" — lowercased (never the
local part). The last-@ split tolerates local parts containing "@"
under a quoting convention; honest callers pass validated emails
(G-315: exactly one "@" by the time an account exists), so an
"@"-less string is an honest-absence case: ""."""
if "@" not in email:
return ""
_, _, domain = email.rpartition("@")
return domain.lower()
def normalize_website_domain(website: str) -> str:
"""The website's host, lowercased — scheme, path, port, and any
userinfo stripped ("HTTPS://Example.COM/openai" → "example.com").
urlsplit parses a SCHEME-qualified URL: a bare "example.com" has no
netloc, so a scheme is prepended for the parse ("example.com" →
"example.com"; "example.com:8443" likewise — the port drops with
the netloc, evidence is registrable not origin-scoped). An
unparseable/empty website yields "" (the honest absence the route
records, never an exception)."""
candidate = website.strip()
if not candidate:
return ""
try:
parts = urlsplit(candidate)
if parts.scheme or parts.netloc:
# Scheme-qualified ("https://…", "//host/…") — parse as-is.
# Non-URL schemes ("mailto:…") land here with no netloc →
# hostname None (honest absence).
hostname = parts.hostname
else:
# Bare host (no scheme): urlsplit would treat it as a
# path — prepending "//" makes it an authority-only URL.
hostname = urlsplit(f"//{candidate}").hostname
except ValueError:
# e.g. an unterminated IPv6 literal — evidence stays advisory,
# never a 500 on the claim route.
return ""
if hostname is None:
return ""
if any(char.isspace() for char in hostname):
return "" # not a domain — honest absence, never junk
return hostname
def domains_match(account_email_domain: str, entity_website_domain: str) -> bool:
"""Simple SUFFIX match (R-65): True when equal or when one endswith
"." + the other. Honest absent input: either side empty → False
(nothing claimed is nothing matched)."""
if not account_email_domain or not entity_website_domain:
return False
if account_email_domain == entity_website_domain:
return True
if account_email_domain.endswith("." + entity_website_domain):
return True
if entity_website_domain.endswith("." + account_email_domain):
return True
return False
def compute_claim_evidence(email: str, website: str) -> dict:
"""The D-089 evidence dict, computed at claim time:
{account_email_domain, entity_website_domain, match}
Pure function over the account email + the entity website — the
claim route snapshots it onto the claim row as provenance JSON.
"""
account_email_domain = normalize_email_domain(email)
entity_website_domain = normalize_website_domain(website)
return {
"account_email_domain": account_email_domain,
"entity_website_domain": entity_website_domain,
"match": domains_match(account_email_domain, entity_website_domain),
}
@@ -0,0 +1,132 @@
"""Stdlib scrypt password KDF (D-086, R-55, REQ-10-001).
`hashlib.scrypt` (stdlib, OpenSSL-backed — D-086: zero new
dependencies) with per-password random salt and a SELF-DESCRIBING
storage format, `scrypt$16384$8$1$<salt_b64>$<hash_b64>`: the KDF
parameters ride the row, so params evolve without invalidating
existing rows (verify parses them from the stored string, never from
the module constants).
Cost budget (R-55 probe, this box's venv — Python 3.11.2): ~47-59 ms
per hash at N=2^14/r=8/p=1/dklen=32, 128·N·r = 16 MiB working set.
Register and login each perform exactly ONE derivation — inside
D-096's "tens of ms, honest" budget. Burst risk (open-risk-1): a
login burst stacks single-threaded derivations; BOTH throttle windows
(Wave 1-2, D-088/G-320) run BEFORE this KDF so a 429'd or capped
attempt never burns KDF CPU.
maxmem discipline (R-55): CPython's default 32 MiB admits the 16 MiB
working set with NO argument; the pin below is exactly 33554432 for
explicitness — never lower (1024 KiB raises ValueError: memory limit
exceeded; the floor is ~17 MiB).
Tests run the REAL KDF (D-096: no fake-hash path) and assert
accept/reject behavior only — never hash bytes.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
#: KDF parameters (D-086) — pinned by test (the PAST_DUE_GRACE_DAYS
#: shape). N=2^14, r=8, p=1, dklen=32: the R-55 probe-verified
#: cost/memory point (~50 ms/hash, 16 MiB).
SCRYPT_N = 2**14
SCRYPT_R = 8
SCRYPT_P = 1
SCRYPT_DKLEN = 32
#: Per-password salt length (bytes) — `secrets.token_bytes(16)`; the
#: random salt is what makes two hashes of one password differ.
_SALT_BYTES = 16
#: R-55: pinned for explicitness — the default 32 MiB admits the
#: 16 MiB working set; NEVER pin below ~17 MiB (1024 KiB raises).
_SCRYPT_MAXMEM = 33554432 # 32 MiB
#: Storage format marker — self-describing rows start with this.
_ALGORITHM = "scrypt"
def _b64e(raw: bytes) -> str:
return base64.b64encode(raw).decode("ascii")
def _b64d(encoded: str) -> bytes:
return base64.b64decode(encoded.encode("ascii"))
def _derive(
password: str, *, salt: bytes, n: int, r: int, p: int, dklen: int
) -> bytes:
"""One scrypt derivation — the ONLY derivation path (register and
verify both route through here, so their costs stay identical)."""
return hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=n,
r=r,
p=p,
dklen=dklen,
maxmem=_SCRYPT_MAXMEM,
)
def hash_password(password: str) -> str:
"""Hash a password with a fresh random salt → the self-describing
`scrypt$16384$8$1$<salt_b64>$<hash_b64>` string.
The module constants bake in at hash time; verify re-reads them
from the stored row, so a future param bump never invalidates
existing rows.
"""
salt = secrets.token_bytes(_SALT_BYTES)
derived = _derive(
password,
salt=salt,
n=SCRYPT_N,
r=SCRYPT_R,
p=SCRYPT_P,
dklen=SCRYPT_DKLEN,
)
return "$".join(
(
_ALGORITHM,
str(SCRYPT_N),
str(SCRYPT_R),
str(SCRYPT_P),
_b64e(salt),
_b64e(derived),
)
)
def verify_password(stored: str, password: str) -> bool:
"""Verify a candidate against a stored self-describing hash.
Parses the params from the stored string (never the module
constants — a row hashed under different params still verifies),
re-derives with the SAME pinned maxmem, compares via
`hmac.compare_digest` (constant-time). Any malformed stored string
is an honest False — never a raise (the auth route maps it to 401).
"""
parts = stored.split("$")
if len(parts) != 6 or parts[0] != _ALGORITHM:
return False
try:
n, r, p = (int(part) for part in parts[1:4])
salt = _b64d(parts[4])
expected = _b64d(parts[5])
if n < 1 or r < 1 or p < 1 or not salt or not expected:
return False
derived = _derive(
password, salt=salt, n=n, r=r, p=p, dklen=len(expected)
)
except (ValueError, TypeError):
# Malformed stored row (bad ints, bad base64, scrypt rejection)
# — an honest reject, never a 500 on the auth path.
return False
return hmac.compare_digest(derived, expected)
@@ -0,0 +1,739 @@
"""EmployerStore — the 8th D-027 store family (D-087/D-095, REQ-10-001).
Four tables on the shared `Settings().db_path` (one SQLite file with the
seven prior stores): `employer_accounts`, `employer_sessions`,
`employer_claims`, `employer_audit_events`. All `employer_`-prefixed —
bare `employer` is marketplace's EmployerRow (marketplace/store.py:144).
Mechanics mirror the probe-verified billing/store.py exactly (R-57):
WAL / synchronous=NORMAL / busy_timeout / foreign_keys pragmas at
connect time, `sa_type` portable columns (String/JSON for Postgres
parity), `_validate()`/`_normalize()` hooks, Protocol + SQLite impl
pair, surfaced IntegrityError, tz-aware → naive → tz-aware boundary
normalization, one-time idempotent `create_schema()` (D-095 — the
tables join an existing shared DB at next boot, zero migration).
Guards baked into the schema (D-087):
- `employer_accounts.email` UNIQUE — one account per email; the
register route's check-first 409 is the fast path, the constraint
is the race backstop and its IntegrityError SURFACES.
- `employer_accounts.employer_id` UNIQUE nullable — the one-account-
one-entity constraint, activates on claim approval (P2).
- `employer_sessions.token_hash` UNIQUE — stores ONLY
`sha256(token)`, NEVER the raw token (D-087): a DB leak yields
nothing usable against the Authorization header namespace.
- G-306: session expiry is evaluated LAZILY at read time against
`expires_at` (login + SESSION_TTL_DAYS) — an expired row reads as
absent; no background timer, no writes on evaluation.
- `employer_claims` landed in P1 per D-095's one-time create_all;
its claim methods (insert_claim/decide_claim/list_claims/get_claim)
+ the G-316 partial UNIQUE index arrive with P2 (D-089).
Boundary (D-027 family): `employers/` never imports `agents/`/`api/`;
this module imports config + base only.
"""
from __future__ import annotations
import hashlib
import sqlite3
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Protocol
from sqlalchemy import Index, UniqueConstraint, event, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.types import JSON, String
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
from ..config import Settings
from .base import CLAIM_STATUSES, validate_email
#: D-087: a login session's TTL. Module constant (not settings) —
#: pinned by test (the PAST_DUE_GRACE_DAYS shape); evaluation is lazy
#: at read time (G-306), never a timer.
SESSION_TTL_DAYS = 7
_TABLES = (
"employer_accounts",
"employer_sessions",
"employer_claims",
"employer_audit_events",
)
#: Audit event vocabulary: `auth` = the Wave 1-2 register/logout
#: account events; `claim` = P2's claim/decide entity events; `manual`
#: = operator action provenance (the billing kind set, employer-side).
#: v0.11 P2: "application" joins the vocabulary (employer-driven hiring
#: transitions append their audit row — the D-089 one-queryable lane).
_AUDIT_EVENT_KINDS = ("auth", "claim", "manual", "application")
class AccountRow(SQLModel, table=True):
"""An employer login account row (D-087).
Bare Literal annotations crash sqlmodel's column inference; the
family pattern (explicit sa_type + _validate hook) is used where
needed. `email` is normalized via base.validate_email (G-315) at
the store boundary — routes normalize too; the store re-derives,
never trusts."""
__tablename__ = "employer_accounts"
__table_args__ = (
UniqueConstraint("email", name="uq_account_email"),
UniqueConstraint("employer_id", name="uq_account_employer"),
)
id: str = Field(primary_key=True, sa_type=String)
#: G-315: plain str, stdlib-validated + stripped/lowercased.
email: str = Field(sa_type=String)
#: Self-describing scrypt string (passwords.py) — never a raw hash.
password_hash: str = Field(sa_type=String)
#: None until a claim is APPROVED (P2) — the UNIQUE nullable
#: one-account-one-entity constraint.
employer_id: str | None = Field(default=None, sa_type=String)
created_at: datetime | None = Field(default=None)
def _validate(self) -> None:
if not self.id:
raise ValueError("id must be non-empty")
if not self.password_hash:
raise ValueError("password_hash must be non-empty")
if self.employer_id is not None and not self.employer_id:
raise ValueError("employer_id must be non-empty when set")
def _normalize(self) -> None:
# G-315 at the store boundary: validate + strip + lowercase
# (raises ValueError on a dishonest email — never trusts).
self.email = validate_email(self.email)
self.created_at = _as_utc(self.created_at) if self.created_at else None
class SessionRow(SQLModel, table=True):
"""A hashed server-side session row (D-087).
`token_hash` is sha256(token) — the raw token is handed to the
client EXACTLY ONCE on login and never persisted anywhere. The
TTL is evaluated LAZILY at read (G-306): an expired row reads as
absent; no timer ever rewrites or deletes it."""
__tablename__ = "employer_sessions"
__table_args__ = (UniqueConstraint("token_hash", name="uq_session_token_hash"),)
id: str = Field(primary_key=True, sa_type=String)
token_hash: str = Field(sa_type=String)
account_id: str = Field(index=True, sa_type=String)
created_at: datetime | None = Field(default=None)
expires_at: datetime | None = Field(default=None)
def _validate(self) -> None:
if not self.id:
raise ValueError("id must be non-empty")
if not self.token_hash:
raise ValueError("token_hash must be non-empty")
if not self.account_id:
raise ValueError("account_id must be non-empty")
def _normalize(self) -> None:
self.created_at = _as_utc(self.created_at) if self.created_at else None
self.expires_at = _as_utc(self.expires_at) if self.expires_at else None
class ClaimRow(SQLModel, table=True):
"""An account's claim on a marketplace EmployerRow entity (D-089).
The table lands now per D-095's one-time create_all; the claim
methods (insert_claim/decide_claim/list_claims) arrive with P2.
`evidence` is the advisory claim-time provenance JSON
{account_email_domain, entity_website_domain, match} (R-65). The
decided_* pair carries operator provenance and is valid only on a
decided row (the past_due_at pairing discipline).
G-316: the partial UNIQUE index uq_pending_claim_per_entity on
employer_id WHERE status='pending' makes one-PENDING-claim-per-
entity a STRUCTURAL guard — check-first alone is TOCTOU. The
approved-owner arm is already structural: employer_accounts.
employer_id UNIQUE (one account : one entity). Rejection frees
the entity — a partial index over status='pending' never touches
rejected rows.
"""
__tablename__ = "employer_claims"
__table_args__ = (
Index(
"uq_pending_claim_per_entity",
"employer_id",
unique=True,
sqlite_where=text("status='pending'"),
),
)
id: str = Field(primary_key=True, sa_type=String)
account_id: str = Field(index=True, sa_type=String)
employer_id: str = Field(index=True, sa_type=String)
status: str = Field(default="pending", sa_type=String)
evidence: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
created_at: datetime | None = Field(default=None)
decided_by: str | None = Field(default=None, sa_type=String)
decided_at: datetime | None = Field(default=None)
def _validate(self) -> None:
if not self.id or not self.account_id or not self.employer_id:
raise ValueError("id, account_id, and employer_id must be non-empty")
if self.status not in CLAIM_STATUSES:
raise ValueError(f"invalid claim status {self.status!r}")
if self.status == "pending" and (
self.decided_by is not None or self.decided_at is not None
):
raise ValueError("decided_by/decided_at are set only on a decided claim")
if self.status in ("approved", "rejected") and self.decided_at is None:
raise ValueError("decided_at must be set on a decided claim")
def _normalize(self) -> None:
self.created_at = _as_utc(self.created_at) if self.created_at else None
self.decided_at = _as_utc(self.decided_at) if self.decided_at else None
class AuditEventRow(SQLModel, table=True):
"""Employer-side audit trail — the BillingEventRow audit shape
(billing/store.py:337-359): auth events (register/logout), claim
events (P2), and operator actions with provenance."""
__tablename__ = "employer_audit_events"
id: int | None = Field(default=None, primary_key=True)
account_id: str | None = Field(default=None, index=True, sa_type=String)
employer_id: str | None = Field(default=None, index=True, sa_type=String)
kind: str = Field(sa_type=String)
name: str = Field(sa_type=String)
detail: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
operator: str | None = Field(default=None, sa_type=String)
created_at: datetime | None = Field(default=None)
def _validate(self) -> None:
if self.kind not in _AUDIT_EVENT_KINDS:
raise ValueError(f"invalid audit event kind {self.kind!r}")
if not self.name:
raise ValueError("name must be non-empty")
def _normalize(self) -> None:
self.created_at = _as_utc(self.created_at) if self.created_at else None
def _as_utc(ts: datetime) -> datetime:
"""SQLite stores naive; read paths re-label tz-aware UTC (D-027 pattern)."""
if ts.tzinfo is None:
return ts.replace(tzinfo=UTC)
return ts
def _hash_token(token: str) -> str:
"""D-087: sessions store ONLY sha256(token) — never the raw token."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _constraint_error(message: str, params: dict[str, Any]) -> IntegrityError:
"""Hand-raised IntegrityError (the guard arms of decide_claim) —
the caller's honest 409, never a silent overwrite. The original
exception is the raised guard itself."""
return IntegrityError(message, params, ValueError(message))
def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None:
"""Per-connection pragmas — mirrors the other D-027 stores."""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
class EmployerStore(Protocol):
"""Persistence contract for employer auth (D-087/D-095)."""
def create_schema(self) -> None:
"""Ensure all four tables exist (idempotent — shared DB file)."""
...
def insert_account(self, *, email: str, password_hash: str) -> tuple[AccountRow, bool]:
"""Check-first insert by email → (row, created); the EXISTING
row rides a duplicate register (route → 409). The UNIQUE
constraint is the race backstop — its IntegrityError SURFACES
(never swallowed, never re-masked as created=False)."""
...
def get_account_by_email(self, email: str) -> AccountRow | None:
"""Point lookup by the normalized email (login path)."""
...
def get_account(self, account_id: str) -> AccountRow | None:
"""Point lookup by id (require_employer_session's account arm)."""
...
def mint_session(
self, *, account_id: str, token: str, now: datetime | None = None
) -> SessionRow:
"""Store sha256(token) with expires_at = now + SESSION_TTL_DAYS
(D-087). The raw token is NEVER persisted; a duplicate
token_hash surfaces as IntegrityError (the constraint is the
only guard — a token is minted fresh per login)."""
...
def get_session_by_token_hash(
self, token_hash: str, *, now: datetime | None = None
) -> SessionRow | None:
"""Point lookup by sha256(token) with LAZY TTL (G-306): an
expired row reads as ABSENT — no timer, no writes on
evaluation; the row itself stays until revoked."""
...
def revoke_session(self, token_hash: str) -> bool:
"""DELETE the session row (logout); True when a row was
removed, False when it was already gone."""
...
def append_audit_event(
self,
kind: str,
name: str,
*,
account_id: str | None = None,
employer_id: str | None = None,
operator: str | None = None,
detail: dict[str, Any] | None = None,
) -> AuditEventRow:
"""Audit log append (auth/claim/manual)."""
...
# -- claims (D-089, REQ-10-002) ---------------------------------------
def insert_claim(
self,
*,
account_id: str,
employer_id: str,
evidence: dict[str, Any] | None = None,
) -> tuple[ClaimRow, bool]:
"""Check-first pending-claim insert (the insert_application
precedent, marketplace/store.py:341-347): an existing PENDING
claim on the entity returns (existing_row, False) — the route's
honest 409 fast path. G-316: the partial UNIQUE index
uq_pending_claim_per_entity backstops the check-first TOCTOU —
a raced duplicate insert SURFACES its IntegrityError (never
swallowed, never re-masked as created=False); the caller maps
it to the SAME 409. Rejected/decided rows never block: rejection
frees the entity for a new claim."""
...
def decide_claim(
self,
claim_id: str,
*,
decision: str,
operator: str,
now: datetime | None = None,
) -> ClaimRow | None:
"""Operator decision on a PENDING claim (D-089): approve → set
employer_accounts.employer_id + decided_by/decided_at + a
`claim` audit event; reject → decided_by/decided_at + the audit
event (the entity stays free). The one-account-one-entity arm
is STRUCTURAL: approving when the account already holds an
entity, or the entity is already owned, raises a hand-raised
IntegrityError (the caller's honest 409 — never a silent
overwrite). Unknown claim id → None (route 404s)."""
...
def list_claims(self, status: str | None = None) -> list[ClaimRow]:
"""Claims newest-first; optional status filter (pending/
approved/rejected — the admin queue's consume-free read path)."""
...
def list_accounts(self) -> list[AccountRow]:
"""All accounts, created-oldest-first (the admin accounts list —
consume-free read path; the queue is pilot-sized)."""
...
def list_audit_events(
self,
*,
account_id: str | None = None,
employer_id: str | None = None,
kind: str | None = None,
name: str | None = None,
limit: int = 100,
) -> list[AuditEventRow]:
"""Audit trail, newest-first (the list_billing_events shape,
billing/store.py:1122-1145); optional account/employer/kind/name
filters — the queryable operator trail (D-089)."""
...
def get_claim(self, claim_id: str) -> ClaimRow | None:
"""Point lookup by id (the admin review path)."""
...
def get_claim_by_account(
self, account_id: str, *, exclude_statuses: tuple[str, ...] = ()
) -> ClaimRow | None:
"""The account's most recent claim NOT in exclude_statuses
(newest-first — created_at DESC then id; the /me claim-state
read). An account with no non-excluded claim → None."""
...
def close(self) -> None:
"""Dispose the engine (app shutdown)."""
...
class SQLiteEmployerStore:
"""SQLite implementation of EmployerStore (D-027/D-087)."""
def __init__(self, db_path: Path | None = None) -> None:
self._db_path: Path = db_path if db_path is not None else Settings().db_path
self._engine = create_engine(
f"sqlite:///{self._db_path}",
connect_args={"check_same_thread": False},
)
event.listen(self._engine, "connect", _sqlite_connect)
self.create_schema()
def create_schema(self) -> None:
"""Ensure all four tables exist (idempotent — shared DB file)."""
SQLModel.metadata.create_all(self._engine)
# -- accounts -----------------------------------------------------------
def insert_account(self, *, email: str, password_hash: str) -> tuple[AccountRow, bool]:
"""Check-first insert (the insert_application pattern): an
existing email returns (existing_row, False) — the register
route's honest 409. The UNIQUE constraint backstops the
check-first TOCTOU; its IntegrityError is SURFACED, never
swallowed."""
row = AccountRow(
id=f"acct-{uuid.uuid4().hex[:16]}",
email=email,
password_hash=password_hash,
employer_id=None,
created_at=datetime.now(UTC),
)
row._validate()
row._normalize()
with Session(self._engine) as session:
existing = session.exec(
select(AccountRow).where(AccountRow.email == row.email)
).one_or_none()
if existing is not None:
existing._normalize()
return existing, False
session.add(row)
session.commit()
session.refresh(row)
row._normalize()
return row, True
def get_account_by_email(self, email: str) -> AccountRow | None:
with Session(self._engine) as session:
row = session.exec(select(AccountRow).where(AccountRow.email == email)).one_or_none()
if row is None:
return None
row._normalize()
return row
def get_account(self, account_id: str) -> AccountRow | None:
with Session(self._engine) as session:
row = session.get(AccountRow, account_id)
if row is None:
return None
row._normalize()
return row
# -- sessions (D-087: hashed-at-rest; G-306: lazy TTL) -------------------
def mint_session(
self, *, account_id: str, token: str, now: datetime | None = None
) -> SessionRow:
if not token:
raise ValueError("token must be non-empty")
moment = _as_utc(now) if now is not None else datetime.now(UTC)
row = SessionRow(
id=f"ses-{uuid.uuid4().hex[:16]}",
# D-087: ONLY the sha256 ever persists — the raw token
# lives in the login response and nowhere else.
token_hash=_hash_token(token),
account_id=account_id,
created_at=moment,
expires_at=moment + timedelta(days=SESSION_TTL_DAYS),
)
row._validate()
row._normalize()
with Session(self._engine) as session:
session.add(row)
# The UNIQUE(token_hash) constraint surfaces a duplicate
# mint as IntegrityError — the caller decides; never
# swallowed here.
session.commit()
session.refresh(row)
row._normalize()
return row
def get_session_by_token_hash(
self, token_hash: str, *, now: datetime | None = None
) -> SessionRow | None:
"""G-306: expired rows read as ABSENT — lazy evaluation, no
timer, no writes on the read path."""
moment = _as_utc(now) if now is not None else datetime.now(UTC)
with Session(self._engine) as session:
row = session.exec(
select(SessionRow).where(SessionRow.token_hash == token_hash)
).one_or_none()
if row is None:
return None
row._normalize()
assert row.expires_at is not None # mint always sets it
if moment >= row.expires_at:
return None # expired — reads as absent, stays on disk
return row
def revoke_session(self, token_hash: str) -> bool:
"""DELETE the row (logout). True = a row was removed; False =
nothing to revoke (unknown/already-revoked token)."""
with Session(self._engine) as session:
row = session.exec(
select(SessionRow).where(SessionRow.token_hash == token_hash)
).one_or_none()
if row is None:
return False
session.delete(row)
session.commit()
return True
# -- audit log ----------------------------------------------------------
def append_audit_event(
self,
kind: str,
name: str,
*,
account_id: str | None = None,
employer_id: str | None = None,
operator: str | None = None,
detail: dict[str, Any] | None = None,
) -> AuditEventRow:
row = AuditEventRow(
account_id=account_id,
employer_id=employer_id,
kind=kind,
name=name,
detail=dict(detail or {}),
operator=operator,
created_at=datetime.now(UTC),
)
row._validate()
row._normalize()
with Session(self._engine) as session:
session.add(row)
session.commit()
session.refresh(row)
row._normalize()
return row
# -- claims (D-089, REQ-10-002) ---------------------------------------
def insert_claim(
self,
*,
account_id: str,
employer_id: str,
evidence: dict[str, Any] | None = None,
) -> tuple[ClaimRow, bool]:
"""Check-first pending-claim insert (the insert_application
precedent): an existing PENDING claim on the entity rides
created=False — never a second pending row. G-316: the partial
UNIQUE index backstops the race — its IntegrityError SURFACES
(the caller maps it to the same honest 409)."""
row = ClaimRow(
id=f"clm-{uuid.uuid4().hex[:16]}",
account_id=account_id,
employer_id=employer_id,
status="pending",
evidence=dict(evidence or {}),
created_at=datetime.now(UTC),
)
row._validate()
row._normalize()
with Session(self._engine) as session:
existing = session.exec(
select(ClaimRow).where(
ClaimRow.employer_id == employer_id,
ClaimRow.status == "pending",
)
).one_or_none()
if existing is not None:
# Fast path: the pending claim exists — the existing
# row rides, created=False (the route's 409).
existing._normalize()
return existing, False
session.add(row)
# G-316: a raced insert lands here — the partial UNIQUE
# index refuses the second PENDING row and its
# IntegrityError SURFACES, never swallowed here.
session.commit()
session.refresh(row)
row._normalize()
return row, True
def decide_claim(
self,
claim_id: str,
*,
decision: str,
operator: str,
now: datetime | None = None,
) -> ClaimRow | None:
"""Approve → employer_accounts.employer_id set + decided_* + a
`claim` audit event; reject → decided_* + the audit event.
The one-owner arms are STRUCTURAL: employer_accounts.employer_id
UNIQUE (approve hands its IntegrityError to the caller) and a
hand-raised guard when the claim is not pending (the caller's
honest 409 — never a silent overwrite)."""
if decision not in ("approved", "rejected"):
raise ValueError(f"invalid decision {decision!r}")
moment = _as_utc(now) if now is not None else datetime.now(UTC)
with Session(self._engine) as session:
row = session.get(ClaimRow, claim_id)
if row is None:
return None
if row.status != "pending":
# Hand-raised IntegrityError — the caller's honest 409
# (deciding a decided claim, the IntegrityError shape).
raise _constraint_error(
f"guard: claim {claim_id!r} is already {row.status!r}",
{"claim_id": claim_id},
)
row.status = decision
row.decided_by = operator
row.decided_at = moment
row._validate()
row._normalize()
if decision == "approved":
account = session.get(AccountRow, row.account_id)
if account is None: # pragma: no cover - FK discipline
raise _constraint_error(
f"guard: claim {claim_id!r} references unknown "
"account — refusing to approve",
{"claim_id": claim_id},
)
if account.employer_id is not None:
# One account : one entity — the account already
# holds an entity. Hand-raised (the account's
# employer_id UNIQUE never sees this UPDATE).
raise _constraint_error(
"guard: employer_accounts.employer_id already set",
{"account_id": row.account_id},
)
account.employer_id = row.employer_id
session.flush()
# The audit row rides the SAME transaction: the decision
# and its provenance land together (all-or-nothing).
event = AuditEventRow(
account_id=row.account_id,
employer_id=row.employer_id,
kind="claim",
name=f"claim.{decision}",
detail={
"claim_id": row.id,
"employer_id": row.employer_id,
"operator": operator,
},
operator=operator,
created_at=moment,
)
event._validate()
event._normalize()
session.add(event)
session.commit()
session.refresh(row)
row._normalize()
return row
def list_claims(self, status: str | None = None) -> list[ClaimRow]:
"""Newest-first; optional status filter (the admin queue)."""
with Session(self._engine) as session:
stmt = select(ClaimRow)
if status is not None:
stmt = stmt.where(ClaimRow.status == status)
rows = session.exec(stmt.order_by(col(ClaimRow.created_at).desc(), ClaimRow.id)).all()
for row in rows:
row._normalize()
return list(rows)
def list_accounts(self) -> list[AccountRow]:
"""Created-oldest-first (the admin accounts list)."""
with Session(self._engine) as session:
rows = session.exec(
select(AccountRow).order_by(col(AccountRow.created_at), AccountRow.id)
).all()
for row in rows:
row._normalize()
return list(rows)
def list_audit_events(
self,
*,
account_id: str | None = None,
employer_id: str | None = None,
kind: str | None = None,
name: str | None = None,
limit: int = 100,
) -> list[AuditEventRow]:
"""Newest-first (the list_billing_events shape); optional
account/employer/kind/name filters — D-089's operator trail."""
with Session(self._engine) as session:
stmt = select(AuditEventRow)
if account_id is not None:
stmt = stmt.where(AuditEventRow.account_id == account_id)
if employer_id is not None:
stmt = stmt.where(AuditEventRow.employer_id == employer_id)
if kind is not None:
stmt = stmt.where(AuditEventRow.kind == kind)
if name is not None:
stmt = stmt.where(AuditEventRow.name == name)
rows = session.exec(
stmt.order_by(
col(AuditEventRow.created_at).desc(),
col(AuditEventRow.id),
).limit(limit)
).all()
for row in rows:
row._normalize()
return list(rows)
def get_claim(self, claim_id: str) -> ClaimRow | None:
with Session(self._engine) as session:
row = session.get(ClaimRow, claim_id)
if row is None:
return None
row._normalize()
return row
def get_claim_by_account(
self, account_id: str, *, exclude_statuses: tuple[str, ...] = ()
) -> ClaimRow | None:
"""The account's most recent claim not in exclude_statuses
(newest-first; the /me claim-state read)."""
with Session(self._engine) as session:
stmt = select(ClaimRow).where(ClaimRow.account_id == account_id)
if exclude_statuses:
stmt = stmt.where(
col(ClaimRow.status).not_in(exclude_statuses)
)
row = session.exec(
stmt.order_by(col(ClaimRow.created_at).desc(), ClaimRow.id)
).first()
if row is None:
return None
row._normalize()
return row
def close(self) -> None:
self._engine.dispose()
+149 -12
View File
@@ -30,6 +30,11 @@ from .identity.mock import MockIdentityProvider
from .identity.store import SQLiteIdentityStore
from .llm import create_provider
from .sandbox import SandboxManager, UnshareBackend
from .sandbox.capability import (
DEGRADE_REMEDIATION,
container_detected,
sandbox_fabric_degraded,
)
from .telemetry.ingest import TraceIntegrityMap
from .telemetry.store import SQLiteTraceStore
from .telemetry.tokens import IngestToken
@@ -171,6 +176,63 @@ def create_app(settings: Settings | None = None) -> FastAPI:
db_path=settings.db_path
)
# Billing engine (v0.9, REQ-9-002): 7th D-027 store family +
# the seeded catalog (D-078 — idempotent upsert, safe on every
# boot). State-injection override preserved.
if getattr(app.state, "billing_store", None) is None:
from .billing.store import SQLiteBillingStore
app.state.billing_store = SQLiteBillingStore(db_path=settings.db_path)
if getattr(app.state, "billing_seeded", None) is not True:
from .billing.catalog import seed_billing_catalog
seed_billing_catalog(app.state.billing_store)
app.state.billing_seeded = True
# Employer auth engine (v0.10, REQ-10-001): 8th D-027 store
# family on the same SQLite file. State-injection override
# preserved (the billing_store getattr-guard template).
if getattr(app.state, "employer_store", None) is None:
from .employers.store import SQLiteEmployerStore
app.state.employer_store = SQLiteEmployerStore(
db_path=settings.db_path
)
# Placement tracking (v0.11, REQ-11-001): 9th D-027 store
# family on the same SQLite file (D-100: the transactional
# transition writes the marketplace application row through the
# shared metadata — one file, one commit). State-injection
# override preserved (the same getattr-guard template).
if getattr(app.state, "placement_store", None) is None:
from .placements.store import SQLitePlacementStore
app.state.placement_store = SQLitePlacementStore(
db_path=settings.db_path
)
# Payment provider (v0.9 P3, REQ-9-003, D-075): mock-first
# default; stripe over the shared httpx pool. G-11 (boot
# survival): a misconfigured real provider must never crash the
# unattended deploy — fall back to mock loudly (the
# voice/embedding pattern).
if getattr(app.state, "payment_provider", None) is None:
from .billing.factory import payment_provider_from_settings
from .billing.mock import MockPaymentProvider
try:
app.state.payment_provider = payment_provider_from_settings(
settings, app.state.http_client
)
except Exception as exc:
logger.warning(
"payment provider %r unavailable (%s); falling back to "
"mock — fix the AI_PAYMENT_* settings and restart",
settings.payment_provider,
exc,
)
app.state.payment_provider = MockPaymentProvider()
# Embedding provider (v0.6 P3, REQ-6-003, D-051): mock-first
# feature-hash default; openai-embed against any OpenAI-compatible
# embeddings endpoint. State-injection override preserved (the
@@ -250,6 +312,24 @@ def create_app(settings: Settings | None = None) -> FastAPI:
logger.exception("sandbox reaper pass failed; retrying next tick")
reaper = asyncio.create_task(_reaper_loop())
# v0.8 honest degrade (D-068/G-208): probe once at boot; if the
# namespace fabric cannot work on this host (unprivileged container
# under docker's default seccomp), say so LOUDLY once with the shared
# remediation constant — the 503 arm in api/sandboxes.py carries the
# same text, so the surfaces cannot drift. The probe is the truth
# source; the container marker only tailors the message.
if sandbox_fabric_degraded():
if container_detected():
logger.warning(
"SANDBOX FABRIC DEGRADED (container detected): %s",
DEGRADE_REMEDIATION,
)
else:
logger.warning(
"SANDBOX FABRIC DEGRADED: %s", DEGRADE_REMEDIATION
)
try:
yield
finally:
@@ -264,6 +344,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
variant_store.close()
defense_store.close()
identity_store.close()
app.state.billing_store.close()
app.state.employer_store.close()
app.state.placement_store.close()
await app.state.http_client.aclose()
app = FastAPI(title="Nextcraft AI Service", version=__version__, lifespan=lifespan)
@@ -275,11 +358,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
# build surface writes workspace files with PUT (engine-client writeFile)
# — v0.3 initially shipped without it and every cross-origin Save failed
# preflight (caught in P7 review; tests/api/test_cors.py pins the policy).
# v0.10 (D-090): "Authorization" joins allow_headers — the employer
# session surface sends Bearer tokens, and without it every
# cross-origin employer fetch dies preflight in the two-port dev +
# D-038 network-mode topologies (the E87 lesson applied before it
# ships; test_cors.py pins it).
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
allow_headers=["Content-Type", "Authorization"],
allow_credentials=False,
)
@@ -312,6 +400,37 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(marketplace_public_router)
app.include_router(marketplace_admin_router)
# v0.9 billing (REQ-9-002/003): the public catalog + webhook read
# surface + the operator checkout routes (admin-gated, D-076). The
# mock-complete route is gated by provider selection at runtime
# (G-301 — admin-gated AND mock-only), so the router mounts whole.
from .api.billing import router as billing_router
from .api.billing_admin import router as billing_admin_router
app.include_router(billing_router)
app.include_router(billing_admin_router)
# v0.10 employers (REQ-10-001): register/login/logout/me session
# surface (D-090 Bearer transport; throttles D-088). P2 (REQ-10-002):
# the claims route rides the sibling router (session-gated, D-089).
from .api.employers import claims_router as employers_claims_router
from .api.employers import router as employers_router
from .api.employers_admin import router as employers_admin_router
app.include_router(employers_router)
app.include_router(employers_claims_router)
# P2 (REQ-10-002, D-089/D-094): the claim review/approve/reject +
# accounts/audit operator routes — require_admin (fail-closed).
app.include_router(employers_admin_router)
# v0.11 placements (REQ-11-002, D-106): the operator lane — the
# generic transition + revoke + the R-80 re-hire; the reporting/
# export reads extend this router in P3/P4. require_admin
# composed at the router level (fail-closed, D-090 no-collision).
from .api.placements_admin import router as placements_admin_router
app.include_router(placements_admin_router)
# v0.6 P5 (REQ-6-006, D-056): dynamic sitemap/robots — request-time
# enumeration (build-time Next sitemap.ts cannot see runtime
# postings under output:'export'); API routes beat the static mount.
@@ -329,26 +448,44 @@ def create_app(settings: Settings | None = None) -> FastAPI:
def _scrub_pii_validation(request, exc):
# P5: the old `# type: ignore[unused-arg]` was inert (the unused-arg
# code is not enabled by this baseline); removed per D-058.
pii_fields = frozenset({"date_of_birth"})
# v0.10 (R-56/open-risk-3): gains "password" + "email" — a pydantic
# 422 on an employer register/login body would echo the raw
# password into err["input"] (the response + client logs) today.
# Security-audit fix (Wave 1-3, task 1-3-01): the field arm alone
# leaks on WHOLE-BODY type errors — a non-dict body (list/str/int)
# errors at loc == ("body",) with input = the ENTIRE submitted
# payload verbatim, which for a mistyped register attempt carries
# the raw password. Any body-level error whose input mentions a
# PII key (dict or payload-shaped) is scrubbed too.
pii_fields = frozenset({"date_of_birth", "password", "email"})
scrubbed = []
for err in exc.errors():
err = dict(err)
# FastAPI's default 422s are JSON-safe EXCEPT ctx payloads
# carrying raw ValueError objects (pydantic model_validator
# errors); strip ctx body-wide so non-PII routes keep their
# 422 shape (msg + loc carry the meaning).
ctx = err.get("ctx")
if isinstance(ctx, dict):
err["ctx"] = {
k: v for k, v in ctx.items() if isinstance(v, (str, int, float, bool))
}
if err.get("loc") and err["loc"][-1] in pii_fields:
# A-305/D1: never echo the submitted value (input) or the
# ctx payload (carries the ValueError); the msg is the
# constraint text and is safe.
err["input"] = "[redacted]"
err.pop("ctx", None)
else:
# FastAPI's default 422s are JSON-safe EXCEPT ctx payloads
# carrying raw ValueError objects (pydantic model_validator
# errors); strip ctx body-wide so non-PII routes keep their
# 422 shape (msg + loc carry the meaning).
ctx = err.get("ctx")
if isinstance(ctx, dict):
err["ctx"] = {
k: v for k, v in ctx.items() if isinstance(v, (str, int, float, bool))
}
elif err.get("loc") and err["loc"][-1] == "body":
# Whole-body error: input is the raw payload — redact
# whenever it mentions a PII key (dict with one, or any
# container whose repr might embed one). The loc+msg still
# carry the failure's meaning; only the echo is dropped.
if not isinstance(err.get("input"), dict) or any(
key in err["input"] for key in pii_fields
):
err["input"] = "[redacted]"
err.pop("ctx", None)
scrubbed.append(err)
return JSONResponse(status_code=422, content={"detail": scrubbed})
@@ -346,12 +346,59 @@ class MarketplaceStore(Protocol):
— never a second application."""
...
def get_application(self, application_id: str) -> Application | None:
"""Point lookup by application id (D-100: the placements
store's transition path reads the row through the shared
metadata; this read serves routes + tests — the get_posting
shape)."""
...
def get_application_by_posting_learner(
self, posting_id: str, learner_id: str
) -> Application | None:
"""v0.11 P5 (G-322, binding): the mine point-read over the
(posting_id, learner_id) unique key — the status panel's
application DISCOVERY on revisit; None = no application (the
surface renders the apply CTA — never client-persisted
application_ids)."""
...
def list_applications_by_postings(
self, posting_ids: list[str], status: str | None = None
) -> list[Application]:
"""v0.11 P2 (D-105/G-319): the employer pipeline's ONE batched
read — every application on the given postings (the caller
scopes to the session's OWN claimed-entity postings first;
this read never enumerates beyond them), created_at DESC then
id; optional status facet."""
...
def list_applications(
self, posting_id: str | None = None
) -> list[Application]:
"""Admin review list, newest first; filterable by posting."""
...
def count_applications_by_posting(
self, posting_ids: list[str]
) -> dict[str, int]:
"""v0.10 P3 (G-319/D-092): batched per-posting application
COUNTS — ONE grouped read for the whole id set (never a
per-posting N+1). Postings with zero applications are absent
from the dict (the caller's .get(id, 0) carries the honest 0).
Employer-facing surfaces compose this for applicant COUNTS
ONLY — learner identifiers never ride any /v1/employers/* read."""
...
def list_postings_by_employer(
self, employer_id: str
) -> list[Posting]:
"""v0.10 P3 (Task 3-1-01): the employer's OWN postings — ALL
lifecycle statuses (draft|published|closed — the owner sees the
full lifecycle, unlike the public board's published-only page),
posted_at DESC then id (the list_postings order)."""
...
def set_posting_tags(self, posting_id: str, tags: PostingTags) -> Posting | None:
"""P2 (D-052): persist derived tags — OVERWRITES the tags column
(single source of truth; re-tag replaces, never appends)."""
@@ -880,6 +927,54 @@ class SQLiteMarketplaceStore:
session.refresh(row)
return row.to_application(), True
def get_application(self, application_id: str) -> Application | None:
"""Point lookup by application id (D-100, the get_posting shape)."""
with Session(self._engine) as session:
row = session.get(ApplicationRow, application_id)
if row is None:
return None
session.refresh(row)
return row.to_application()
def get_application_by_posting_learner(
self, posting_id: str, learner_id: str
) -> Application | None:
"""v0.11 P5 (G-322): the mine point-read — one row over the
insert_application unique key (the get_posting point-read
discipline)."""
with Session(self._engine) as session:
row = session.exec(
select(ApplicationRow).where(
ApplicationRow.posting_id == posting_id,
ApplicationRow.learner_id == learner_id,
)
).one_or_none()
if row is None:
return None
session.refresh(row)
return row.to_application()
def list_applications_by_postings(
self, posting_ids: list[str], status: str | None = None
) -> list[Application]:
"""v0.11 P2 (D-105/G-319): ONE batched read over the given
postings — created_at DESC then id; optional status facet.
Empty posting set → empty list (the honest no-postings case)."""
if not posting_ids:
return []
with Session(self._engine) as session:
stmt = select(ApplicationRow).where(
col(ApplicationRow.posting_id).in_(posting_ids)
)
if status is not None:
stmt = stmt.where(ApplicationRow.status == status)
rows = session.exec(
stmt.order_by(
col(ApplicationRow.created_at).desc(), ApplicationRow.id
)
).all()
return [r.to_application() for r in rows]
def list_applications(
self, posting_id: str | None = None
) -> list[Application]:
@@ -892,6 +987,44 @@ class SQLiteMarketplaceStore:
).all()
return [r.to_application() for r in rows]
def count_applications_by_posting(
self, posting_ids: list[str]
) -> dict[str, int]:
"""G-319: ONE grouped read — SELECT posting_id, COUNT(*) ...
GROUP BY posting_id — for the whole id set. Zero-application
postings absent (honest 0 via the caller's .get(id, 0))."""
if not posting_ids:
return {}
with Session(self._engine) as session:
rows = session.exec(
select(
ApplicationRow.posting_id,
func.count(),
)
.where(col(ApplicationRow.posting_id).in_(posting_ids))
.group_by(ApplicationRow.posting_id)
).all()
return {posting_id: count for posting_id, count in rows}
def list_postings_by_employer(
self, employer_id: str
) -> list[Posting]:
"""The owner's full-lifecycle read (Task 3-1-01): ALL statuses,
posted_at DESC then id (the list_postings order — drafts with
no posted_at sort last within the None-ordered tie band)."""
with Session(self._engine) as session:
rows = (
session.exec(
select(PostingRow)
.where(PostingRow.employer_id == employer_id)
.order_by(
col(PostingRow.posted_at).desc(), PostingRow.id
)
)
.all()
)
return [r.to_posting() for r in rows]
# -- P4-A: enrichment (append-only, D-053) --------------------------------
def append_enrichment(
@@ -0,0 +1,380 @@
"""Placement contracts: the D-099 hiring-state matrix, the D-101
event vocabulary + GENESIS, and the placement wire shapes (REQ-11-001).
Boundary (D-031 family): `placements/` never imports `api/`; stdlib +
pydantic + the marketplace WIRE contracts only. The store imports the
marketplace ROW (the sanctioned D-100 cross-family seam — one
transaction covers both tables on the shared DB file).
Id honesty (A-305, the family rule): every id in this module is an
opaque handle minted UNIQUELY per creation call — never derived from
user input or a deterministic hash of it.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
#: D-099 application status vocabulary (snake_case, the billing
#: `past_due` precedent): the hiring lifecycle. `submitted` is the
#: v0.6 status (zero migration); hired/rejected/withdrawn are TERMINAL.
ApplicationStatus = Literal[
"submitted",
"in_review",
"interview",
"offer",
"hired",
"rejected",
"withdrawn",
]
#: Tuple mirror of the Literal (docs + tests import this).
APPLICATION_STATUSES: tuple[ApplicationStatus, ...] = (
"submitted",
"in_review",
"interview",
"offer",
"hired",
"rejected",
"withdrawn",
)
#: Terminal statuses — no outgoing edges (D-099).
TERMINAL_APPLICATION_STATUSES: tuple[ApplicationStatus, ...] = (
"hired",
"rejected",
"withdrawn",
)
#: D-099 transition matrix (the billing/store.py `_VALID_TRANSITIONS`
#: shape): forward-only WITH skip-forward — from any non-terminal
#: status, any LATER stage and any terminal are legal (a fast-track
#: `submitted → hired` is a legal hire — CIRR counts the hire, not the
#: ceremony); NO backward moves; terminals have empty tuples.
TRANSITION_MATRIX: dict[str, tuple[str, ...]] = {
"submitted": (
"in_review",
"interview",
"offer",
"hired",
"rejected",
"withdrawn",
),
"in_review": ("interview", "offer", "hired", "rejected", "withdrawn"),
"interview": ("offer", "hired", "rejected", "withdrawn"),
"offer": ("hired", "rejected", "withdrawn"),
"hired": (),
"rejected": (),
"withdrawn": (),
}
#: Placement lifecycle (D-102): created `active`; the ONLY transition
#: is the operator correction `active → revoked`. Revoked rows stay in
#: history (the partial unique index never touches them — re-hire
#: creates a fresh row, R-80).
PlacementStatus = Literal["active", "revoked"]
PLACEMENT_STATUSES: tuple[PlacementStatus, ...] = ("active", "revoked")
#: D-101 actor vocabulary (the billing `_BILLING_EVENT_KINDS` shape).
ACTOR_TYPES: tuple[str, ...] = ("employer", "learner", "operator", "system")
#: D-101 event-name vocabulary: application-stage events (one per
#: transition — NO backfill: pre-v0.11 `submitted` rows carry only
#: their created_at, never a fabricated event, A-304) + the placement
#: lane (created on hired, revoked on the D-102 correction, rehired
#: on the R-80 operator re-hire).
EVENT_APPLICATION_TRANSITION: dict[str, str] = {
"in_review": "application.in_review",
"interview": "application.interview",
"offer": "application.offer",
"hired": "application.hired",
"rejected": "application.rejected",
"withdrawn": "application.withdrawn",
}
EVENT_PLACEMENT_CREATED = "placement.created"
EVENT_PLACEMENT_REVOKED = "placement.revoked"
EVENT_PLACEMENT_REHIRED = "placement.rehired"
EVENT_NAMES: tuple[str, ...] = (
*EVENT_APPLICATION_TRANSITION.values(),
EVENT_PLACEMENT_CREATED,
EVENT_PLACEMENT_REVOKED,
EVENT_PLACEMENT_REHIRED,
)
#: D-101: the chain's genesis — the first event's prev_hash. A module
#: constant, test-pinned (never a row — the walk starts here).
GENESIS_HASH = "0" * 64
class InvalidTransition(ValueError):
"""A matrix-illegal application status move (D-099) or placement
status move (D-102) — the route's honest 409."""
def is_terminal(status: str) -> bool:
"""True when the application status has no outgoing edges."""
return status in TERMINAL_APPLICATION_STATUSES
def validate_transition(from_status: str, to_status: str) -> None:
"""D-099 matrix validation — raises InvalidTransition on any
illegal move (backward, terminal-source, unknown status)."""
if from_status not in TRANSITION_MATRIX:
raise InvalidTransition(f"unknown application status {from_status!r}")
if to_status not in APPLICATION_STATUSES:
raise InvalidTransition(f"unknown application status {to_status!r}")
if to_status not in TRANSITION_MATRIX[from_status]:
legal = TRANSITION_MATRIX[from_status] or "(terminal)"
raise InvalidTransition(
f"illegal transition {from_status!r} → {to_status!r} "
f"(legal: {legal})"
)
class HireTransitionRequest(BaseModel):
"""The hire body (D-103/G-324): role_title is employer-attested
free text — never silently the posting title — with the house
Field bounds (empty-string → 422); start_date is the employment
start DATE (first day of work), never a datetime: timezone-free by
construction, future + past allowed (D-103)."""
role_title: str = Field(min_length=1, max_length=200)
start_date: date
class PlacementRead(BaseModel):
"""A placement record over the wire (the PlacementRow read shape).
employer/learner/posting are ID REFERENCES (G-302) — resolved from
source rows at read/export, never trusted denormalized copies."""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
learner_id: str = Field(min_length=1)
employer_id: str = Field(min_length=1)
posting_id: str = Field(min_length=1)
application_id: str = Field(min_length=1)
role_title: str = Field(min_length=1)
start_date: date
status: PlacementStatus
created_at: datetime
decided_at: datetime
class PlacementEventRead(BaseModel):
"""One ledger event over the wire (the PlacementEventRow read
shape) — application-stage events carry application_id with
placement_id None; the placement lane carries placement_id set."""
model_config = ConfigDict(frozen=True)
id: int
application_id: str | None = None
placement_id: str | None = None
event_type: str = Field(min_length=1)
actor_type: str = Field(min_length=1)
actor_ref: str = Field(min_length=1)
payload: dict[str, Any] = Field(default_factory=dict)
prev_hash: str = Field(min_length=1)
hash: str = Field(min_length=1)
created_at: datetime
class EmployerApplicationSummary(BaseModel):
"""An employer-facing application row (D-105/G-319): the pipeline
list + per-application reads. learner_id IS visible here — the
G-319 supersession scopes it to applications on the session's OWN
claimed-entity postings (the cross-entity arm 403s carry no data;
the own-postings LIST stays counts-only — the pin split)."""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
posting_id: str = Field(min_length=1)
learner_id: str = Field(min_length=1)
status: ApplicationStatus
created_at: datetime | None = None
class EmployerApplicationsResponse(BaseModel):
"""GET /v1/employers/applications response: own-pipeline
applications (every status) + per-status facet counts (every
status key ALWAYS present — a zero-count status carries its
honest 0)."""
model_config = ConfigDict(frozen=True)
applications: list[EmployerApplicationSummary] = Field(default_factory=list)
facets: dict[str, int] = Field(default_factory=dict)
total: int = 0
class ApplicationTransitionResponse(BaseModel):
"""The transition result (per-target routes): the application's
new status + the placement record when the transition created
one (hire)."""
model_config = ConfigDict(frozen=True)
application_id: str = Field(min_length=1)
status: ApplicationStatus
placement: PlacementRead | None = None
class AdminTransitionRequest(BaseModel):
"""POST /v1/placements/admin/applications/{id}/transition body
(D-106/G-327): `to` is a CLOSED Literal of the seven D-099
statuses — unknown values die at pydantic (422), matrix-illegal
known values reach the store (409 InvalidTransition); the route
never sees a status outside the vocabulary. role_title/start_date
are required iff to=hired (enforced at the route, the honest 422
with required-fields detail)."""
model_config = ConfigDict(frozen=True)
to: ApplicationStatus
role_title: str | None = Field(default=None, min_length=1, max_length=200)
start_date: date | None = None
#: D-107: the guiding outcome's north star (PROJECT.md line 7 — the
#: noun is LEARNERS, not placements).
GUIDING_GOAL = 1000
#: D-108/G-321/G-325 (R-67/R-68): the export's methodology block —
#: written from in-tree facts, stating its own three pilot gaps
#: explicitly (an audit methodology that omits its own verification
#: limits is A-304-class dishonesty) AND the chain's proof scope
#: (G-321: what it proves, what it does not — wholesale DB replacement
#: is undetectable by the walk alone; export-to-export continuity is
#: the auditor's anchor — plus the event↔row cross-consistency check).
#: CIRR kept as naming, not citation — the text stands on its own
#: definitions (R-67).
EXPORT_METHODOLOGY: dict[str, Any] = {
"placement_definition": (
"A placement is a `placement` row created by the hired transition "
"of a marketplace application, with an employer-attested role_title "
"and start_date (the employment start DATE, D-103), status=active "
"at export time. One ACTIVE placement per application is "
"structurally enforced (the partial unique index); revoked "
"placements stay in history and are excluded from every count."
),
"counting_rules": (
"The guiding metric is DISTINCT learners with at least one active "
"placement, all-time cumulative toward the 1,000-learner guiding "
"goal (a learner placed twice counts ONCE — the outcome's noun is "
"learners, not placements). Revoked placements are excluded from "
"every count. Cohort month is the calendar month of start_date "
"(YYYY-MM) — the employment-start month, not a graduation cohort "
"(no graduation events exist in a marketplace; this divergence "
"from CIRR cohort convention is deliberate and documented)."
),
"verification": (
"WHAT THE CHAIN PROVES: the global sha256 ledger (events ordered "
"by id, each hash covering the canonical JSON of its row + the "
"prior event's hash, from the GENESIS constant to the head) "
"detects in-place mutation of any event row and makes a chain "
"fork structurally impossible (UNIQUE(prev_hash)). WHAT IT DOES "
"NOT PROVE: a wholesale replacement of the database (delete every "
"event, re-append a fresh plausible chain from GENESIS) walks "
"green — the walk alone cannot detect it. THE AUDITOR'S ANCHOR "
"is export-to-export continuity: a prior export's ledger.events "
"must appear as an exact PREFIX of a later export's events, and "
"the prior export's head_hash must equal the hash at that prefix "
"position in the later export. EVENT↔ROW CROSS-CONSISTENCY: "
"every active placement must have a placement.created event; "
"every revoked placement a placement.revoked event; summary."
"revoked_count must equal the number of placement.revoked "
"events. EVIDENCE CLASS: verification is employer-principal "
"attestation (an authenticated hire by a claim-verified, "
"operator-approved employer principal) plus operator oversight — "
"recorded decisions, not third-party employment verification."
),
"scope": (
"PILOT LIMITS, stated explicitly (R-67): (a) no minimum-employed-"
"duration check — a placement here is a RECORDED HIRE, not a "
"90-day sustained role; (b) no salary or compensation tracking "
"(out of scope by decision D-098); (c) verification is employer-"
"attested with operator oversight, NOT third-party employment "
"verification — no external auditor integration exists at "
"pilot; this export IS the third-party-auditor deliverable. "
"Coverage: every placement in the database, unbounded by date. "
"CIRR-style describes this export's format; the definitions "
"above are the operative ones."
),
}
class PlacementOverview(BaseModel):
"""GET /v1/placements/admin/overview response (D-107): counts
computed from rows at query time — NEVER denormalized counters
(G-302/G-306). The guiding metric is DISTINCT learners with ≥1
active placement (a learner placed twice counts ONCE); revoked
excluded from every count; cohort month = the calendar month of
start_date (YYYY-MM, employment-start month — not hired month)."""
model_config = ConfigDict(frozen=True)
total_active_placements: int = 0
distinct_learners_placed: int = 0
guiding_goal: int = GUIDING_GOAL
by_employer: list[dict[str, Any]] = Field(default_factory=list)
by_cohort_month: list[dict[str, Any]] = Field(default_factory=list)
class EmployerPlacementsResponse(BaseModel):
"""GET /v1/employers/placements response (D-109 surface 2's data
source): the claimed entity's OWN placements (active + revoked —
the employer sees their full history), created_at then id."""
model_config = ConfigDict(frozen=True)
placements: list[PlacementRead] = Field(default_factory=list)
total: int = 0
class LearnerApplicationSummary(BaseModel):
"""The application row on the learner status read (D-104,
REQ-11-005): the mine-visible fields only — ids + status + the
apply timestamp (nullable per the v0.6 row shape — honest
absence); references resolve at render (G-302), never
denormalized copies."""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
posting_id: str = Field(min_length=1)
learner_id: str = Field(min_length=1)
status: ApplicationStatus
created_at: datetime | None = None
class LearnerApplicationStatusResponse(BaseModel):
"""GET /v1/marketplace/applications/{application_id}?learner_id=
response (REQ-11-005, D-104): current status + the FULL id-ordered
timestamped event history for THIS application (a scoped query —
never another learner's events) + the placement record when hired
(the honest null otherwise — never a fabricated placement, A-304)."""
model_config = ConfigDict(frozen=True)
application: LearnerApplicationSummary
events: list[PlacementEventRead] = Field(default_factory=list)
placement: PlacementRead | None = None
class MineApplicationResponse(BaseModel):
"""GET /v1/marketplace/applications/mine?posting_id=&learner_id=
response (G-322, binding): {application_id, status} — the status
panel's application DISCOVERY read on revisit; 404 = no application
on this posting (the surface renders the apply CTA — the panel
NEVER depends on client-persisted application_ids)."""
model_config = ConfigDict(frozen=True)
application_id: str = Field(min_length=1)
status: ApplicationStatus
@@ -0,0 +1,912 @@
"""Placement persistence — the 9th D-027 store (REQ-11-001, D-112).
TWO tables on the shared DB file (R-72): `placement` (immutable at
creation except status — D-102) + `placement_event` (the append-only
GLOBAL hash chain — D-101). Mechanics mirror employers/store.py
exactly (pragmas, _validate/_normalize, Protocol + SQLite pair,
surfaced IntegrityError, _constraint_error helper).
NO DELETE PATH (R-70, binding): this module contains NO delete method
and NO DELETE SQL — deleting the top ledger row would make SQLite's
INTEGER PRIMARY KEY reuse max+1 and break the chain. Pinned by test.
STATUS-COLUMN OWNERSHIP (R-78, binding): the marketplace store writes
`application.status` EXACTLY ONCE at creation (insert_application);
THIS store is the ONLY post-creation writer — via the ONE
`transition_application` method, never a second append path (D-100:
the status write + the ledger event + the placement-on-hired land in
ONE transaction; a two-commit alternative leaves a crash window that
voids the tamper-evidence this milestone sells).
Canonical serialization (D-101/R-69/R-79, binding): the hash covers
the canonical JSON of {id, application_id, placement_id, event_type,
actor_type, actor_ref, payload, created_at} + prev_hash — sorted keys,
compact separators, created_at ALWAYS `_as_utc(dt).isoformat()` (ONE
pinned normalization: naive-in-SQLite vs aware-on-read must never
drift), payload values str/int/bool/None only (floats REJECTED —
R-69).
"""
from __future__ import annotations
import hashlib
import json
import sqlite3
import uuid
from datetime import UTC, date, datetime
from pathlib import Path
from typing import Any, Protocol
from sqlalchemy import Index, event, func, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.types import JSON, String
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
from ..config import Settings
from ..marketplace.store import ApplicationRow
from .base import (
ACTOR_TYPES,
EVENT_APPLICATION_TRANSITION,
EVENT_PLACEMENT_CREATED,
EVENT_PLACEMENT_REHIRED,
EVENT_PLACEMENT_REVOKED,
EXPORT_METHODOLOGY,
GENESIS_HASH,
InvalidTransition,
validate_transition,
)
_PLACEMENT_ID_PREFIX = "plc-"
def _as_utc(ts: datetime) -> datetime:
"""SQLite stores naive; read paths re-label tz-aware UTC (D-027
pattern). Aware NON-UTC inputs CONVERT to the true instant (the
security review's P0-1: re-labeling preserves the offset — an
append-time canonical form of 12:00-04:00 would re-read as
12:00+00:00 and permanently break the chain, with no delete path
to recover)."""
if ts.tzinfo is None:
return ts.replace(tzinfo=UTC)
return ts.astimezone(UTC)
def _constraint_error(message: str, params: dict[str, Any]) -> IntegrityError:
"""Hand-raised IntegrityError (the guard arms) — the caller's
honest 409, never a silent overwrite."""
return IntegrityError(message, params, ValueError(message))
def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None:
"""Per-connection pragmas — mirrors the other D-027 stores."""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def _check_payload(value: Any) -> None:
"""R-69: payload values are str/int/bool/None ONLY — floats (and
any nested container) are rejected at the store boundary: float
representation is a canonical-serialization drift the chain must
never depend on."""
if isinstance(value, bool) or value is None:
return
if isinstance(value, str):
return
if isinstance(value, int):
return
raise ValueError(
f"payload values must be str/int/bool/None, got {type(value).__name__}"
)
class PlacementRow(SQLModel, table=True):
"""A placement record (D-102): created by the hired transition,
IMMUTABLE except `status` (exactly one transition active → revoked,
operator-only by route). role_title/start_date/decided_at and the
four ID references are set once and NEVER rewritten — a silent
rewrite is exactly the tamper the ledger exists to detect.
G-316 shape: the partial UNIQUE index uq_active_placement_per_
application makes one-ACTIVE-placement-per-application a
STRUCTURAL guard — revoked rows stay in history and never match
the partial WHERE, so a re-hire after a mistaken revoke creates a
fresh row (R-80) and an operator fat-finger never permanently
erases a learner from the ≥1,000 count.
"""
__tablename__ = "placement"
__table_args__ = (
Index(
"uq_active_placement_per_application",
"application_id",
unique=True,
sqlite_where=text("status='active'"),
),
)
id: str = Field(primary_key=True, sa_type=String)
learner_id: str = Field(index=True, sa_type=String)
employer_id: str = Field(sa_type=String)
#: G-302: ID references resolved from source rows at read/export.
posting_id: str = Field(sa_type=String)
application_id: str = Field(index=True, sa_type=String)
role_title: str = Field(sa_type=String)
#: D-103: ISO YYYY-MM-DD — a calendar date, no datetime coercion.
start_date: str = Field(sa_type=String)
status: str = Field(default="active", sa_type=String)
created_at: datetime | None = Field(default=None)
#: D-102: the hired transition's store-stamped created_at (G-326:
#: the re-hire arm stamps the RE-HIRE event's created_at).
decided_at: datetime | None = Field(default=None)
def _validate(self) -> None:
if not self.id:
raise ValueError("id must be non-empty")
for field_name in (
"learner_id",
"employer_id",
"posting_id",
"application_id",
"role_title",
"start_date",
):
if not getattr(self, field_name):
raise ValueError(f"{field_name} must be non-empty")
if self.status not in ("active", "revoked"):
raise ValueError(f"invalid placement status {self.status!r}")
def _normalize(self) -> None:
self.created_at = _as_utc(self.created_at) if self.created_at else None
self.decided_at = _as_utc(self.decided_at) if self.decided_at else None
class PlacementEventRow(SQLModel, table=True):
"""One append-only ledger event (D-101): ONE GLOBAL chain ordered
by autoincrement id. Each row stores prev_hash (the prior event's
hash; GENESIS_HASH for the first) + its own hash = sha256 over the
canonical JSON + prev_hash. UNIQUE(prev_hash) makes a chain fork
structurally impossible — two events claiming the same
predecessor surface IntegrityError (the G-316 backstop class).
Application-stage events carry application_id (placement_id
None); the placement lane (created/revoked/rehired) carries
placement_id set from hired onward.
"""
__tablename__ = "placement_event"
id: int | None = Field(default=None, primary_key=True)
application_id: str | None = Field(default=None, index=True, sa_type=String)
placement_id: str | None = Field(default=None, sa_type=String)
event_type: str = Field(sa_type=String)
actor_type: str = Field(sa_type=String)
actor_ref: str = Field(sa_type=String)
payload: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
prev_hash: str = Field(unique=True, sa_type=String)
hash: str = Field(default="", sa_type=String)
created_at: datetime | None = Field(default=None)
def _validate(self) -> None:
if self.id is not None and self.id < 1:
raise ValueError("id must be a positive int when set")
if not self.event_type:
raise ValueError("event_type must be non-empty")
if self.actor_type not in ACTOR_TYPES:
raise ValueError(f"unknown actor_type {self.actor_type!r}")
if not self.actor_ref:
raise ValueError("actor_ref must be non-empty")
if not self.prev_hash:
raise ValueError("prev_hash must be non-empty")
for value in self.payload.values():
_check_payload(value)
def _normalize(self) -> None:
self.created_at = _as_utc(self.created_at) if self.created_at else None
def canonical_json(self) -> str:
"""D-101/R-79: the canonical serialization — sorted keys,
compact separators, created_at ALWAYS `_as_utc(dt).isoformat()`
(ONE pinned normalization; the hashing and reading paths share
this function so they can never drift)."""
return json.dumps(
{
"id": self.id,
"application_id": self.application_id,
"placement_id": self.placement_id,
"event_type": self.event_type,
"actor_type": self.actor_type,
"actor_ref": self.actor_ref,
"payload": self.payload,
"created_at": (
_as_utc(self.created_at).isoformat()
if self.created_at is not None
else None
),
},
sort_keys=True,
separators=(",", ":"),
)
def _compute_hash(canonical_json: str, prev_hash: str) -> str:
"""sha256 over the canonical JSON + prev_hash (D-101 — the D-087
stdlib sha256 precedent; hexdigest)."""
return hashlib.sha256((canonical_json + prev_hash).encode("utf-8")).hexdigest()
class PlacementStore(Protocol):
"""Persistence contract for placements (D-101/D-102/D-112)."""
def create_schema(self) -> None:
"""Ensure both tables exist (idempotent — shared DB file)."""
...
def transition_application(
self,
application_id: str,
to_status: str,
actor_type: str,
actor_ref: str,
role_title: str | None = None,
start_date: date | None = None,
now: datetime | None = None,
) -> tuple[ApplicationRow, PlacementRow | None]:
"""D-100: the ONE transactional transition — update the
marketplace application row + append the ledger event + create
the PlacementRow on hired, ALL IN ONE COMMIT. One append per
transaction per lane (transition/revoke/rehire each append
exactly once); R-70: the appends race nothing — the chain's
prev_hash read-then-write is single-threaded-safe through
SQLite's writer serialization + the UNIQUE backstop."""
...
def verify_chain(self) -> tuple[bool, str]:
"""Recompute the walk from GENESIS_HASH → head; (ok, head_hash)."""
...
def get_placement(self, placement_id: str) -> PlacementRow | None:
...
def get_placement_by_application(
self, application_id: str
) -> PlacementRow | None:
"""v0.11 P5 (REQ-11-005): the learner status read's placement
arm — the ACTIVE placement on this application (the G-316
partial-unique shape: at most one; revoked-only history →
None, the honest null — the events carry the correction)."""
...
def list_placements(self, status: str | None = None) -> list[PlacementRow]:
...
def get_application_events(self, application_id: str) -> list[PlacementEventRow]:
...
def list_events(self) -> list[PlacementEventRow]:
...
def revoke_placement(
self, placement_id: str, actor_ref: str, now: datetime | None = None
) -> PlacementRow | None:
"""D-102: the one status-only mutation active → revoked + the
placement.revoked event, one transaction (operator-only by
route)."""
...
def rehire_placement(
self,
application_id: str,
role_title: str,
start_date: date,
actor_ref: str,
now: datetime | None = None,
) -> PlacementRow:
"""R-80/G-326: a FRESH PlacementRow + placement.rehired event
WITHOUT touching the application's terminal status — the
correction lane. decided_at = the re-hire event's created_at.
An existing ACTIVE placement on the application surfaces its
IntegrityError (the caller's honest 409)."""
...
def list_placements_by_employer(
self, employer_id: str
) -> list[PlacementRow]:
"""v0.11 P3 (D-109): the claimed entity's OWN placements
(active + revoked — the employer sees their full history),
created_at then id (the deterministic order)."""
...
def build_export(self, now: datetime | None = None) -> dict[str, Any]:
"""v0.11 P4 (D-108/G-321/G-325): the self-contained audit
document — {schema_version, generated_at, summary, cohort,
evidence_bundles, methodology, ledger} — resolved from SOURCE
ROWS at export time (G-302 snapshot-at-export). The summary
route renders FROM this dict (one truth source)."""
...
def close(self) -> None:
...
class SQLitePlacementStore:
"""SQLite implementation of PlacementStore (D-027/D-112)."""
def __init__(self, db_path: Path | None = None) -> None:
self._db_path: Path = db_path if db_path is not None else Settings().db_path
self._engine = create_engine(
f"sqlite:///{self._db_path}",
connect_args={"check_same_thread": False},
)
event.listen(self._engine, "connect", _sqlite_connect)
self.create_schema()
def create_schema(self) -> None:
"""Ensure both tables exist (idempotent — shared DB file)."""
SQLModel.metadata.create_all(self._engine)
# -- chain core (D-101/R-70) -------------------------------------------
@staticmethod
def _chain_head(session: Session) -> tuple[int | None, str]:
"""(last event id, its hash) or (None, GENESIS_HASH) — the
next append's prev_hash source."""
row = session.exec(
select(PlacementEventRow).order_by(col(PlacementEventRow.id).desc())
).first()
if row is None or row.hash == "":
return None, GENESIS_HASH
return row.id, row.hash
@staticmethod
def _append_event(
session: Session,
*,
application_id: str | None,
placement_id: str | None,
event_type: str,
actor_type: str,
actor_ref: str,
payload: dict[str, Any] | None,
prev_hash: str,
created_at: datetime,
) -> PlacementEventRow:
"""R-70: flush-then-hash-then-commit — the row's own
autoincrement id participates in its own hash, so the append
flushes (id assigned), computes the hash, sets the column, and
flushes again. The CALLER owns the transaction (D-100: the
status write + the events + the placement land in one commit);
this helper never commits."""
row = PlacementEventRow(
application_id=application_id,
placement_id=placement_id,
event_type=event_type,
actor_type=actor_type,
actor_ref=actor_ref,
payload=dict(payload or {}),
prev_hash=prev_hash,
hash="",
created_at=created_at,
)
row._validate()
row._normalize()
session.add(row)
# flush #1: the autoincrement id is assigned
session.flush()
assert row.id is not None # flush assigns it
# the hash covers the canonical JSON + prev_hash
row.hash = _compute_hash(row.canonical_json(), row.prev_hash)
# flush #2: the hash column lands
session.flush()
return row
def verify_chain(self) -> tuple[bool, str]:
"""Recompute the walk GENESIS_HASH → head (R-70: O(n) sha256
over tiny rows — milliseconds at pilot scale)."""
with Session(self._engine) as session:
prev = GENESIS_HASH
last_hash = GENESIS_HASH
rows = session.exec(select(PlacementEventRow).order_by(col(PlacementEventRow.id))).all()
for row in rows:
row._normalize()
expected = _compute_hash(row.canonical_json(), prev)
if row.prev_hash != prev or row.hash != expected:
return False, last_hash
prev = row.hash
last_hash = row.hash
return True, last_hash
# -- the ONE transition (D-100) ------------------------------------------
def transition_application(
self,
application_id: str,
to_status: str,
actor_type: str,
actor_ref: str,
role_title: str | None = None,
start_date: date | None = None,
now: datetime | None = None,
) -> tuple[ApplicationRow, PlacementRow | None]:
"""D-100: ONE Session on the placements engine covering BOTH
tables + the marketplace application row (the sanctioned
cross-family write — R-72: global SQLModel metadata means the
placements engine sees ApplicationRow; both stores share the
one SQLite file, so one commit covers everything atomically,
the decide_claim all-or-nothing precedent).
R-78: this is the ONLY post-creation writer of
application.status — never a second caller.
"""
moment = _as_utc(now) if now is not None else datetime.now(UTC)
with Session(self._engine) as session:
application = session.get(ApplicationRow, application_id)
if application is None:
raise InvalidTransition(f"unknown application {application_id!r}")
validate_transition(application.status, to_status)
if to_status == "hired":
if not role_title:
raise ValueError("role_title is required on hire (D-103)")
if start_date is None:
raise ValueError("start_date is required on hire (D-103)")
# the status write (the only post-creation writer — R-78);
# the pre-transition status is captured BEFORE the mutation
from_status = application.status
application.status = to_status
application._normalize()
prev_id, prev_hash = self._chain_head(session)
payload: dict[str, Any] = {"from_status": from_status, "to_status": to_status}
self._append_event(
session,
application_id=application_id,
placement_id=None,
event_type=EVENT_APPLICATION_TRANSITION[to_status],
actor_type=actor_type,
actor_ref=actor_ref,
payload=payload,
prev_hash=prev_hash,
created_at=moment,
)
placement: PlacementRow | None = None
if to_status == "hired":
assert role_title is not None and start_date is not None
placement = PlacementRow(
id=f"{_PLACEMENT_ID_PREFIX}{uuid.uuid4().hex[:16]}",
learner_id=application.learner_id,
employer_id=self._posting_employer_id(session, application.posting_id),
posting_id=application.posting_id,
application_id=application.id,
role_title=role_title,
start_date=start_date.isoformat(),
status="active",
created_at=moment,
decided_at=moment,
)
placement._validate()
placement._normalize()
session.add(placement)
# the placement row must exist before its event references it
session.flush()
_, prev_hash_2 = self._chain_head(session)
self._append_event(
session,
application_id=application_id,
placement_id=placement.id,
event_type=EVENT_PLACEMENT_CREATED,
actor_type=actor_type,
actor_ref=actor_ref,
payload={
"role_title": role_title,
"start_date": start_date.isoformat(),
},
prev_hash=prev_hash_2,
created_at=moment,
)
session.commit()
session.refresh(application)
application._normalize()
if placement is not None:
session.refresh(placement)
placement._normalize()
return application, placement
@staticmethod
def _posting_employer_id(session: Session, posting_id: str) -> str:
"""G-302: resolve the posting's employer_id from the SOURCE row
at placement-creation time — never trusted from the caller."""
from ..marketplace.store import PostingRow
posting = session.get(PostingRow, posting_id)
if posting is None:
raise _constraint_error(
f"guard: application references unknown posting "
f"{posting_id!r} — refusing to create placement",
{"posting_id": posting_id},
)
return posting.employer_id
# -- reads (consume-free, G-302 ID references) ---------------------------
def get_placement(self, placement_id: str) -> PlacementRow | None:
with Session(self._engine) as session:
row = session.get(PlacementRow, placement_id)
if row is None:
return None
row._normalize()
return row
def get_placement_by_application(
self, application_id: str
) -> PlacementRow | None:
"""v0.11 P5 (REQ-11-005): the ACTIVE placement on this
application — the G-316 partial unique index makes
at-most-one structural; revoked-only history → None (the
honest null — never a fabricated placement, A-304)."""
with Session(self._engine) as session:
row = session.exec(
select(PlacementRow).where(
PlacementRow.application_id == application_id,
PlacementRow.status == "active",
)
).one_or_none()
if row is None:
return None
row._normalize()
return row
def list_placements(self, status: str | None = None) -> list[PlacementRow]:
"""created_at then id (the list_postings ordering discipline,
R-73 — deterministic export ordering)."""
with Session(self._engine) as session:
stmt = select(PlacementRow)
if status is not None:
stmt = stmt.where(PlacementRow.status == status)
rows = session.exec(
stmt.order_by(col(PlacementRow.created_at), PlacementRow.id)
).all()
for row in rows:
row._normalize()
return list(rows)
def get_application_events(self, application_id: str) -> list[PlacementEventRow]:
"""id-ordered (the chain order) — scoped to ONE application."""
with Session(self._engine) as session:
rows = session.exec(
select(PlacementEventRow)
.where(PlacementEventRow.application_id == application_id)
.order_by(col(PlacementEventRow.id))
).all()
for row in rows:
row._normalize()
return list(rows)
def list_events(self) -> list[PlacementEventRow]:
"""The whole ledger, id-ordered (the export embeds every event
so the auditor's walk is offline — R-70)."""
with Session(self._engine) as session:
rows = session.exec(
select(PlacementEventRow).order_by(col(PlacementEventRow.id))
).all()
for row in rows:
row._normalize()
return list(rows)
def count_events(self, event_type: str | None = None) -> int:
with Session(self._engine) as session:
stmt = select(func.count()).select_from(PlacementEventRow)
if event_type is not None:
stmt = stmt.where(PlacementEventRow.event_type == event_type)
return session.exec(stmt).one()
# -- the D-102 correction lane ------------------------------------------
def revoke_placement(
self, placement_id: str, actor_ref: str, now: datetime | None = None
) -> PlacementRow | None:
"""D-102: the one status-only mutation active → revoked + the
placement.revoked event, one transaction (operator-only by
route). None = unknown placement (route 404s)."""
moment = _as_utc(now) if now is not None else datetime.now(UTC)
with Session(self._engine) as session:
row = session.get(PlacementRow, placement_id)
if row is None:
return None
if row.status != "active":
raise InvalidTransition(
f"placement {placement_id!r} is {row.status!r} — "
"only an active placement can be revoked"
)
row.status = "revoked"
row._validate()
row._normalize()
_, prev_hash = self._chain_head(session)
self._append_event(
session,
application_id=row.application_id,
placement_id=row.id,
event_type=EVENT_PLACEMENT_REVOKED,
actor_type="operator",
actor_ref=actor_ref,
payload={"placement_id": placement_id},
prev_hash=prev_hash,
created_at=moment,
)
session.commit()
session.refresh(row)
row._normalize()
return row
def rehire_placement(
self,
application_id: str,
role_title: str,
start_date: date,
actor_ref: str,
now: datetime | None = None,
) -> PlacementRow:
"""R-80/G-326: the operator re-hire — a FRESH PlacementRow +
placement.rehired event WITHOUT touching the application's
terminal status (hired → hired is a 409 on every
matrix-validated surface; placements, not applications, are
the correction lane). decided_at = the re-hire event's
store-stamped created_at (G-326 — D-102's stamp rule extended).
The partial unique index is the guard: an existing ACTIVE
placement on the application refuses the insert and its
IntegrityError SURFACES (the caller's honest 409 — revoked
rows never match the partial WHERE, the exact G-316 property).
"""
if not role_title:
raise ValueError("role_title is required on re-hire (D-103)")
if start_date is None:
raise ValueError("start_date is required on re-hire (D-103)")
moment = _as_utc(now) if now is not None else datetime.now(UTC)
with Session(self._engine) as session:
application = session.get(ApplicationRow, application_id)
if application is None:
raise InvalidTransition(f"unknown application {application_id!r}")
if application.status != "hired":
raise InvalidTransition(
f"application {application_id!r} is {application.status!r} — "
"re-hire is the correction lane for a hired application"
)
placement = PlacementRow(
id=f"{_PLACEMENT_ID_PREFIX}{uuid.uuid4().hex[:16]}",
learner_id=application.learner_id,
employer_id=self._posting_employer_id(session, application.posting_id),
posting_id=application.posting_id,
application_id=application.id,
role_title=role_title,
start_date=start_date.isoformat(),
status="active",
created_at=moment,
# G-326: the re-hire event's stamp, not the original hire's
decided_at=moment,
)
placement._validate()
placement._normalize()
session.add(placement)
# the partial unique index refuses a second ACTIVE row —
# its IntegrityError SURFACES (never swallowed, the
# insert_claim discipline)
session.flush()
_, prev_hash = self._chain_head(session)
self._append_event(
session,
application_id=application.id,
placement_id=placement.id,
event_type=EVENT_PLACEMENT_REHIRED,
actor_type="operator",
actor_ref=actor_ref,
payload={
"role_title": role_title,
"start_date": start_date.isoformat(),
},
prev_hash=prev_hash,
created_at=moment,
)
session.commit()
session.refresh(placement)
placement._normalize()
return placement
def list_placements_by_employer(
self, employer_id: str
) -> list[PlacementRow]:
"""v0.11 P3 (D-109): the claimed entity's OWN placements
(active + revoked — the full history), created_at then id."""
with Session(self._engine) as session:
rows = session.exec(
select(PlacementRow)
.where(PlacementRow.employer_id == employer_id)
.order_by(col(PlacementRow.created_at), PlacementRow.id)
).all()
for row in rows:
row._normalize()
return list(rows)
# -- the audit export (v0.11 P4, D-108/G-321/G-325/R-73) -----------------
EXPORT_SCHEMA_VERSION = 1
def build_export(self, now: datetime | None = None) -> dict[str, Any]:
"""D-108: ONE self-contained JSON document via this single
builder (the export route AND the summary route render from
the SAME dict — the summary can never drift from the export).
G-325: evidence_bundles cover EVERY placement (active AND
revoked) — the correction history (hired → revoked → rehired)
is precisely what an auditor most wants to inspect; cohort
stays active-only; revoked_count equals the placement.revoked
events (cross-checkable, rides the G-321(c) methodology).
R-73 determinism: events by id (chain order); by_cohort_month
ascending; cohort/evidence ordered created_at then id; float
absence (counts are ints by construction); generated_at is
export-only metadata (a function of the request, not the rows
— R-79) and is EXCLUDED from determinism comparison.
"""
moment = _as_utc(now) if now is not None else datetime.now(UTC)
all_placements = self.list_placements() # created_at then id
active = [row for row in all_placements if row.status == "active"]
revoked = [row for row in all_placements if row.status == "revoked"]
events = self.list_events() # id order — the chain order
# summary (the D-107 computation — one implementation, reused)
learner_ids = {row.learner_id for row in active}
by_month: dict[str, int] = {}
for row in active:
month = row.start_date[:7]
by_month[month] = by_month.get(month, 0) + 1
# evidence bundles: G-302 — resolved from SOURCE ROWS at export
with Session(self._engine) as session:
from ..marketplace.store import ApplicationRow, PostingRow
evidence_bundles: list[dict[str, Any]] = []
cohort: list[dict[str, Any]] = []
for row in all_placements:
application = session.get(ApplicationRow, row.application_id)
posting = session.get(PostingRow, row.posting_id)
transition_history = [
{
"id": event.id,
"event_type": event.event_type,
"actor_type": event.actor_type,
"actor_ref": event.actor_ref,
"created_at": (
_as_utc(event.created_at).isoformat()
if event.created_at is not None
else None
),
}
for event in events
if event.application_id == row.application_id
]
hired_at: str | None = None
for e in events:
if (
e.application_id == row.application_id
and e.event_type == "application.hired"
and e.created_at is not None
):
hired_at = _as_utc(e.created_at).isoformat()
break
decision_provenance = {
"hired_event": hired_at,
"decided_at": (
_as_utc(row.decided_at).isoformat()
if row.decided_at is not None
else None
),
}
bundle: dict[str, Any] = {
"placement_id": row.id,
"placement_status": row.status,
"role_title": row.role_title,
"start_date": row.start_date,
"learner_id": row.learner_id,
"employer_id": row.employer_id,
"posting_record": (
{
"id": posting.id,
"title": posting.title,
"employer_id": posting.employer_id,
"status": posting.status,
}
if posting is not None
else None
),
"application_record": (
{
"id": application.id,
"learner_id": application.learner_id,
"posting_id": application.posting_id,
"status": application.status,
"created_at": (
_as_utc(application.created_at).isoformat()
if application.created_at is not None
else None
),
}
if application is not None
else None
),
# D-101/A-304: NO backfill — pre-v0.11 submitted rows
# carry only their created_at, never a fabricated
# application.submitted event
"transition_history": transition_history,
"decision_provenance": decision_provenance,
}
evidence_bundles.append(bundle)
if row.status == "active":
cohort.append(
{
"placement_id": row.id,
"learner_id": row.learner_id,
"employer_id": row.employer_id,
"posting_id": row.posting_id,
"application_id": row.application_id,
"role_title": row.role_title,
"start_date": row.start_date,
}
)
ok, head_hash = self.verify_chain()
if not ok:
# P4 review P2-1: a raise (not an assert — stripped under
# python -O) keeps the export FAIL-CLOSED on a broken
# chain: no document is ever served that would not walk.
raise RuntimeError(
"placement ledger chain verification failed — "
"refusing to build the audit export"
)
return {
"schema_version": self.EXPORT_SCHEMA_VERSION,
"generated_at": moment.isoformat(),
"summary": {
"distinct_learners_placed": len(learner_ids),
"total_active_placements": len(active),
"revoked_count": len(revoked),
"by_cohort_month": [
{"month": month, "count": count}
for month, count in sorted(by_month.items())
],
},
"cohort": cohort,
"evidence_bundles": evidence_bundles,
"methodology": EXPORT_METHODOLOGY,
"ledger": {
"events": [
{
"id": event.id,
"application_id": event.application_id,
"placement_id": event.placement_id,
"event_type": event.event_type,
"actor_type": event.actor_type,
"actor_ref": event.actor_ref,
"payload": event.payload,
"created_at": (
_as_utc(event.created_at).isoformat()
if event.created_at is not None
else None
),
"prev_hash": event.prev_hash,
"hash": event.hash,
}
for event in events
],
"head_hash": head_hash,
},
}
def close(self) -> None:
self._engine.dispose()
@@ -0,0 +1,86 @@
"""Sandbox capability probe — the honest degrade truth source (D-068, v0.8).
Runtime promotion of the test-tier userns probe (``tests/helpers/userns.py``
mechanism, verbatim): a REAL ``unshare --user --map-root-user`` attempt is
the truth source for whether the namespace fabric can provision learner
environments on this host. Container detection (``/.dockerenv`` or the
``NC_CONTAINER`` marker the image sets) is belt-and-suspenders context for
the remediation text — the degrade decision itself is ALWAYS the probe,
never a guess from the marker (a privileged container with working userns
must NOT report degraded).
The single remediation constant (G-208) is shared by the boot notice
(``main.py`` lifespan) and the 503 arm (``api/sandboxes.py``) so the two
surfaces can never drift.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
#: Remediation text (G-208): names the unprivileged default, the seccomp
#: constraint, the privileged variant with exact flags, and the host-side
#: alternative. One constant — boot notice and 503 payload import it, so
#: they cannot drift apart.
DEGRADE_REMEDIATION = (
"learner sandbox builds are unavailable on this host: the namespace "
"fabric needs unprivileged user namespaces and the capability probe "
"failed. The nextcraft container runs unprivileged by default and "
"docker's default seccomp profile denies the nested `unshare --user` "
"the fabric requires. Options: (1) run the image with "
"`--security-opt seccomp=unconfined` (and `--security-opt "
"apparmor=unconfined` where apparmor applies) on a host that allows "
"unprivileged userns; or (2) run the stack binary-side on the host "
"(`nextcraft deploy --service`) where the fabric works natively. "
"Everything else in this deployment is unaffected."
)
_probe_result: bool | None = None
def userns_available() -> bool:
"""True only if `unshare` exists AND `--user --map-root-user` works here.
The result is cached per process (the probe spawns a subprocess; one
answer per boot is the contract — the boot notice and every 503 read
the same truth).
"""
global _probe_result
if _probe_result is None:
_probe_result = _probe()
return _probe_result
def _probe() -> bool:
if shutil.which("unshare") is None:
return False
try:
result = subprocess.run(
["unshare", "--user", "--map-root-user", "true"],
capture_output=True,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0
def container_detected() -> bool:
"""Context marker (not the degrade truth): /.dockerenv or NC_CONTAINER.
Used by the boot notice to tailor the message and by the CLI's
container-mode branches. A privileged container with working userns
is NOT degraded — the probe alone decides that.
"""
if Path("/.dockerenv").exists():
return True
import os
return os.environ.get("NC_CONTAINER") == "1"
def sandbox_fabric_degraded() -> bool:
"""The degrade decision: the probe fails. Marker never overrides it."""
return not userns_available()
+1 -1
View File
@@ -9,4 +9,4 @@
"lint": "bash scripts/lint.sh",
"typecheck": "bash scripts/typecheck.sh"
}
}
}
+7 -2
View File
@@ -14,7 +14,12 @@ dependencies = [
"pydantic-settings>=2.15,<2.16",
"httpx>=0.28,<0.29",
"sse-starlette>=3.4,<3.5",
"sqlmodel>=0.0.24,<0.1",
"sqlmodel>=0.0.24,<0.0.43",
# ^ P1 CI finding: sqlmodel 0.0.43+ (up to 0.0.47 resolved in fresh CI
# venvs) tightens Literal-arg inference on pydantic models — 15 mypy
# arg-type errors surface (DefenseRecord/TelemetryEvent/DefenseTurn).
# The v0.7-shipped line was 0.0.42; cap below .43 until the Literal
# annotations are tightened deliberately (per-module overrides later).
"sqlalchemy>=2.0,<2.1",
"websockets>=13,<16",
"aiofiles>=24.1,<26",
@@ -143,4 +148,4 @@ warn_unused_configs = true
# never imported by checked code). Overrides are reserved for deps that
# genuinely lack stubs — currently NONE, so no ignore_missing_imports
# overrides are listed; warn_unused_configs would flag any added no-op.
ignore_missing_imports = false
ignore_missing_imports = false
+1 -1
View File
@@ -72,4 +72,4 @@ fi
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet -e "$APP_DIR[dev]"
echo "bootstrap complete: $VENV"
echo "bootstrap complete: $VENV"
+1 -1
View File
@@ -37,4 +37,4 @@ HOST="${AI_HOST:-0.0.0.0}"
PORT="${AI_PORT:-8420}"
cd "$APP_DIR"
exec "$VENV/bin/uvicorn" ai_service.main:app --reload --host "$HOST" --port "$PORT"
exec "$VENV/bin/uvicorn" ai_service.main:app --reload --host "$HOST" --port "$PORT"
+1 -1
View File
@@ -11,4 +11,4 @@ if [ ! -x "$VENV/bin/ruff" ]; then
fi
cd "$APP_DIR"
exec "$VENV/bin/ruff" check .
exec "$VENV/bin/ruff" check .
+1 -1
View File
@@ -16,4 +16,4 @@ if [ ! -x "$VENV/bin/mypy" ]; then
fi
cd "$APP_DIR"
exec "$VENV/bin/mypy" "$@"
exec "$VENV/bin/mypy" "$@"
@@ -187,7 +187,10 @@ class TestProctorEndpoint:
from ai_service.variants.store import SQLiteVariantStore
from ai_service.voice.defense_store import SQLiteDefenseStore
app = create_app(Settings(provider="mock"))
# P1 CI finding: bare Settings() defaults db_path to ~/.nextcraft —
# hermeticity rule violated (CI job containers have no ~/.nextcraft;
# sqlite fails "unable to open database file"). Pin all state to tmp.
app = create_app(Settings(provider="mock", db_path=tmp_path / "app.db"))
provider = RecordingProvider(PROCTOR_JSON)
app.state.provider = provider
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
+1 -1
View File
@@ -35,7 +35,7 @@ class CoachingMock(MockProvider):
@pytest.fixture()
def client(tmp_path) -> Iterator[TestClient]:
app = create_app(Settings(provider="mock"))
app = create_app(Settings(provider="mock", db_path=tmp_path / "app.db"))
app.state.provider = CoachingMock()
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
+22
View File
@@ -80,3 +80,25 @@ def test_credentials_never_allowed(client: TestClient) -> None:
},
)
assert resp.headers.get("access-control-allow-credentials") != "true"
def test_preflight_allows_authorization_header(
client: TestClient,
) -> None:
"""v0.10 (D-090, E87 lesson): the employer session surface sends
`Authorization: Bearer <token>` — the preflight grant must carry
the header or every cross-origin employer fetch dies preflight in
the two-port dev + D-038 network-mode topologies."""
resp = client.options(
"/v1/employers/auth/login",
headers={
"Origin": ALLOWED_ORIGIN,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Authorization, Content-Type",
},
)
assert resp.status_code == 200, f"preflight failed: {resp.status_code}"
allowed = resp.headers["access-control-allow-headers"]
assert "authorization" in allowed.lower(), (
f"Authorization missing from CORS headers: {allowed}"
)
@@ -57,6 +57,7 @@ def app(tmp_path: Path):
provider="mock",
voice_provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db", # hermeticity (P1 CI finding)
)
application = create_app(settings)
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
@@ -135,6 +136,7 @@ class TestBrowserFallback:
provider="mock",
voice_provider="browser",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app-b.db", # hermeticity (P1 CI finding)
)
)
identity_store = SQLiteIdentityStore(db_path=tmp_path / "identity-b.db")
+1 -1
View File
@@ -46,7 +46,7 @@ class StreamingMock(MockProvider):
@pytest.fixture()
def client(tmp_path: Path) -> Iterator[TestClient]:
app = create_app(Settings(provider="mock"))
app = create_app(Settings(provider="mock", db_path=tmp_path / "app.db"))
app.state.provider = StreamingMock()
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
+1 -1
View File
@@ -51,7 +51,7 @@ class BrokenJSON(MockProvider):
@pytest.fixture()
def client(tmp_path: Path) -> Iterator[TestClient]:
app = create_app(Settings(provider="mock"))
app = create_app(Settings(provider="mock", db_path=tmp_path / "app.db"))
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
app.state.variant_store = SQLiteVariantStore(db_path=tmp_path / "v.db")
@@ -64,8 +64,13 @@ class StubBackend:
"""Structural SandboxBackend: lays out the workdir, spawns nothing.
Tracks spawn/destroy calls so lifespan shutdown behaviour is assertable.
`structural = True` exempts it from the v0.8 degrade guard (D-068): the
stub fabric never touches userns, so it must stay usable inside
unprivileged CI job containers (D-063 fast lane).
"""
structural = True
def __init__(self) -> None:
self.spawned_specs: list[SandboxSpec] = []
self.destroyed_ids: list[str] = []
@@ -0,0 +1,6 @@
"""Billing engine tests (REQ-9-001) — module usable WITHOUT any route.
P1 Must-have: mock provider + webhooks core proven by direct
instantiation only (no app, no routes, zero network). The store tests
(test_store.py) join this package with the data-engineer's Task 1-1-02.
"""
+129
View File
@@ -0,0 +1,129 @@
"""/v1/billing/catalog route tests (REQ-9-002).
Hermetic: a tmp-path billing store state-injected into a real
create_app (the G-9 precedent). The catalog route is PUBLIC — no auth
anywhere in this phase's surface. The shape assertions double as the
py half of the dual-schema pins (apps/web/tests/pins.test.ts pins the
TS half — G-108 discipline).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.billing.catalog import SEED_PACKAGES, SEED_PLANS
from ai_service.billing.store import SQLiteBillingStore
from ai_service.config import Settings
from ai_service.main import create_app
from ..conftest import SUITE_LEARNERS
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
def make_client(tmp_path: Path) -> TestClient:
"""Full app; the billing store seeds via the lifespan (D-078
idempotent boot seeding — exactly what production runs)."""
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
)
return TestClient(create_app(settings))
@pytest.fixture()
def client(tmp_path: Path) -> Iterator[TestClient]:
with make_client(tmp_path) as c:
yield c
class TestCatalogRoute:
def test_catalog_unauthenticated_200(self, client: TestClient) -> None:
response = client.get("/v1/billing/catalog")
assert response.status_code == 200
body = response.json()
assert set(body.keys()) == {"packages", "plans"}
def test_seeded_package_ids_and_prices(self, client: TestClient) -> None:
"""The catalog mirrors the pricing page's tier structure
(D-078/D-079): single $49, bundle $399, starter $99/mo, pro
$299/mo — integer cents (D-081)."""
body = client.get("/v1/billing/catalog").json()
packages = {p["id"]: p for p in body["packages"]}
assert packages["pkg-single"]["price_cents"] == 4900
assert packages["pkg-single"]["posts_limit"] == 1
assert packages["pkg-single"]["duration_days"] == 30
assert packages["pkg-bundle"]["price_cents"] == 39900
assert packages["pkg-bundle"]["posts_limit"] == 10
assert packages["pkg-bundle"]["duration_days"] == 90
plans = {p["id"]: p for p in body["plans"]}
assert plans["plan-starter"]["price_cents"] == 9900
assert plans["plan-starter"]["interval"] == "month"
assert plans["plan-pro"]["price_cents"] == 29900
def test_wire_shape_matches_contracts(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""Field-for-field against the pydantic contracts — the py half
of the dual-schema pins (G-108)."""
body = client.get("/v1/billing/catalog").json()
package_fields = {
"id",
"name",
"price_cents",
"posts_limit",
"duration_days",
"features",
}
assert set(body["packages"][0].keys()) == package_fields
plan_fields = {"id", "name", "price_cents", "interval", "features"}
assert set(body["plans"][0].keys()) == plan_fields
for pkg in body["packages"]:
assert isinstance(pkg["price_cents"], int) # D-081: no floats
assert pkg["price_cents"] >= 0
for plan in body["plans"]:
assert isinstance(plan["price_cents"], int)
assert plan["interval"] in ("month", "year")
def test_prices_ascending(self, client: TestClient) -> None:
body = client.get("/v1/billing/catalog").json()
pkg_prices = [p["price_cents"] for p in body["packages"]]
plan_prices = [p["price_cents"] for p in body["plans"]]
assert pkg_prices == sorted(pkg_prices)
assert plan_prices == sorted(plan_prices)
def test_route_stable_across_calls(self, client: TestClient) -> None:
first = client.get("/v1/billing/catalog").json()
second = client.get("/v1/billing/catalog").json()
assert first == second
def test_seeded_catalog_matches_module_constants(
self, billing_store: SQLiteBillingStore
) -> None:
"""The store's seed + the module constants are the same data
(D-078 — one source of truth). list_catalog orders by price
ascending; the constants keep display order — compare as sets."""
from ai_service.billing.catalog import seed_billing_catalog
seed_billing_catalog(billing_store)
seed_billing_catalog(billing_store) # idempotent
packages, plans = billing_store.list_catalog()
assert {p.id for p in packages} == {p.id for p in SEED_PACKAGES}
assert {p.id for p in plans} == {p.id for p in SEED_PLANS}
by_id = {p.id: p for p in packages}
for const in SEED_PACKAGES:
seeded = by_id[const.id]
assert seeded.price_cents == const.price_cents
assert seeded.posts_limit == const.posts_limit
assert seeded.features == const.features
+167
View File
@@ -0,0 +1,167 @@
"""Base contract tests (Task 1-1-01, REQ-9-001, D-081/D-083).
Pins:
- money is integer cents with ge=0 (D-081): negative prices rejected on
Package AND Plan; the int wire type is the dual-schema pin's backend
half (P2's pins.test.ts checks the TS side)
- the canonical event-name enum is CLOSED (D-083/R-43): unknown names
rejected by CanonicalBillingEvent, all five canonical names accepted
- contract defaults: features lists default empty, payload dict default
empty, CheckoutSessionSpec carries the reconciliation key
"""
from __future__ import annotations
from typing import Any
import pytest
from pydantic import ValidationError
from ai_service.billing.base import (
CANONICAL_EVENT_NAMES,
CanonicalBillingEvent,
CheckoutSessionSpec,
Package,
Plan,
)
def _package(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "pkg-single",
"name": "Single post",
"price_cents": 4900,
"posts_limit": 1,
"duration_days": 30,
"features": ["1 job posting"],
}
base.update(overrides)
return base
def _plan(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "plan-pro",
"name": "Pro",
"price_cents": 29900,
"interval": "month",
"features": ["talent access"],
}
base.update(overrides)
return base
class TestPackageContract:
def test_valid_package_roundtrip(self) -> None:
pkg = Package(**_package())
assert pkg.price_cents == 4900
assert pkg.posts_limit == 1
assert pkg.duration_days == 30
assert pkg.features == ["1 job posting"]
def test_negative_price_cents_rejected(self) -> None:
with pytest.raises(ValidationError, match="price_cents"):
Package(**_package(price_cents=-1))
def test_zero_cents_allowed(self) -> None:
# ge=0 — a $0 promo tier is a valid catalog row (D-081 pins
# NON-negative, not positive).
assert Package(**_package(price_cents=0)).price_cents == 0
def test_cents_must_be_int_no_floats(self) -> None:
# D-081: no floats anywhere in billing — 49.5 dollars must not
# silently coerce. model_validate keeps mypy from rejecting the
# deliberately-typed-wrong input (the runtime is the pin).
with pytest.raises(ValidationError):
Package.model_validate(_package(price_cents=49.5))
def test_posts_limit_and_duration_positive(self) -> None:
with pytest.raises(ValidationError):
Package(**_package(posts_limit=0))
with pytest.raises(ValidationError):
Package(**_package(duration_days=0))
class TestPlanContract:
def test_valid_plan_roundtrip(self) -> None:
plan = Plan(**_plan())
assert plan.price_cents == 29900
assert plan.interval == "month"
def test_negative_price_cents_rejected(self) -> None:
with pytest.raises(ValidationError, match="price_cents"):
Plan(**_plan(price_cents=-100))
def test_unknown_interval_rejected(self) -> None:
with pytest.raises(ValidationError, match="interval"):
Plan(**_plan(interval="fortnight"))
def test_features_default_empty(self) -> None:
plan = Plan(id="plan-x", name="X", price_cents=0, interval="month")
assert plan.features == []
class TestCanonicalEventEnum:
def test_all_five_canonical_names_accepted(self) -> None:
for name in CANONICAL_EVENT_NAMES:
event = CanonicalBillingEvent(name=name, payload={"id": "evt_1"})
assert event.name == name
def test_canonical_names_are_the_d083_set(self) -> None:
# D-083's exact vocabulary (R-43: OUR names, mapped at the
# provider boundary — never vendor names).
assert set(CANONICAL_EVENT_NAMES) == {
"checkout.completed",
"invoice.paid",
"invoice.payment_failed",
"subscription.cancelled",
"subscription.renewed",
}
def test_unknown_event_name_rejected(self) -> None:
# Vendor names must NOT validate as canonical (R-43 — the
# boundary maps, never trusts). model_validate: the dict payload
# is deliberately mistyped; the runtime enum is the pin.
with pytest.raises(ValidationError, match="checkout.session.completed"):
CanonicalBillingEvent.model_validate(
{"name": "checkout.session.completed"}
)
def test_payload_defaults_empty_dict(self) -> None:
assert CanonicalBillingEvent(name="invoice.paid").payload == {}
class TestCheckoutSessionSpec:
def test_spec_shape(self) -> None:
spec = CheckoutSessionSpec(
mode="payment",
item_id="pkg-single",
price_cents=4900,
item_name="Single Post",
interval="",
success_url="https://app.example/success",
cancel_url="https://app.example/cancel",
client_reference_id="sess_local_1",
metadata={"employer_id": "E1"},
)
assert spec.mode == "payment"
assert spec.metadata == {"employer_id": "E1"}
assert spec.price_cents == 4900
assert spec.item_name == "Single Post"
def test_client_reference_id_required(self) -> None:
# R-34: our reconciliation key — no spec without it.
with pytest.raises(ValidationError):
CheckoutSessionSpec.model_validate(
{"mode": "payment", "item_id": "pkg-single"}
)
def test_unknown_mode_rejected(self) -> None:
with pytest.raises(ValidationError):
CheckoutSessionSpec.model_validate(
{
"mode": "rent",
"item_id": "pkg-single",
"client_reference_id": "sess_local_1",
}
)
@@ -0,0 +1,255 @@
"""Posting entitlement enforcement tests (REQ-9-004, MH-4a..4d).
create_posting composes the billing entitlement check AFTER the admin
gate: 402 with an upgrade-CTA dict payload on no-package (MH-4a) or
exhausted (MH-4b), 201 + consumption on valid (MH-4b), unlimited
bypass (MH-4c), expired degrades to 402 (MH-4d), and G-309: the
seeded ingest NEVER hits the enforcement.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.billing.store import SQLiteBillingStore
from ai_service.config import Settings
from ai_service.main import create_app
from ..conftest import SUITE_LEARNERS
ADMIN_TOKEN = "test-entitlement-admin"
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
def make_client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> TestClient:
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=SecretStr(ADMIN_TOKEN),
)
app = create_app(settings)
app.state.billing_store = billing_store # state-injection (G-9)
return TestClient(app)
@pytest.fixture()
def client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[TestClient]:
with make_client(tmp_path, billing_store) as c:
yield c
def admin_headers() -> dict[str, str]:
return {"X-Admin-Token": ADMIN_TOKEN}
def create_posting(client: TestClient, employer: str):
return client.post(
"/v1/marketplace/admin/postings",
headers=admin_headers(),
json={"employer_id": employer, "title": "Entitlement Test Role"},
)
def grant_posts(
billing_store: SQLiteBillingStore, employer: str, limit: int | None
) -> None:
"""A direct purchase grant (the store path the webhook would take;
P4 tests bypass checkout — that's P3's proven surface)."""
session_row = billing_store.insert_checkout_session(
provider_session_id=f"cs_test_{employer}",
employer_id=employer,
item_id="pkg-single" if limit == 1 else "pkg-bundle",
item_name="Test Grant",
price_cents=0,
mode="payment",
)
billing_store.create_purchase_from_session(
session_row, posts_limit=limit, duration_days=30
)
class TestEnforcement:
def test_no_package_402_with_cta(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4a: no purchase → 402 + upgrade_cta payload."""
response = create_posting(client, "emp-broke")
assert response.status_code == 402
detail = response.json()["detail"]
assert detail["reason"] == "no_active_package"
assert "upgrade_cta" in detail
assert "/v1/billing/admin/checkout" in detail["upgrade_cta"]
def test_exhausted_402(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4b (half): 1-post package consumed → next is 402."""
grant_posts(billing_store, "emp-single", 1)
first = create_posting(client, "emp-single")
assert first.status_code == 201
second = create_posting(client, "emp-single")
assert second.status_code == 402
assert second.json()["detail"]["reason"] == "posting_limit_reached"
def test_valid_package_consumes(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4b (half): a 10-post bundle → 201s + posts_used grows."""
grant_posts(billing_store, "emp-bundle", 10)
for i in range(3):
response = create_posting(client, "emp-bundle")
assert response.status_code == 201, f"post {i}"
entitlement = billing_store.get_entitlement("emp-bundle")
assert entitlement["purchase"]["posts_used"] == 3
def test_unlimited_bypass(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4c: a manual unlimited grant never blocks."""
grant_posts(billing_store, "emp-enterprise", None)
for _ in range(5):
response = create_posting(client, "emp-enterprise")
assert response.status_code == 201
def test_expired_degrades_to_402(
self,
tmp_path: Path,
client: TestClient,
billing_store: SQLiteBillingStore,
) -> None:
"""MH-4d: an expired purchase is skipped — honest 402."""
from datetime import UTC, datetime, timedelta
from sqlalchemy import text
from sqlmodel import Session
grant_posts(billing_store, "emp-expired", 10)
with Session(billing_store._engine) as session:
session.execute(
text(
"UPDATE billing_purchases SET expires_at = :past "
"WHERE employer_id = 'emp-expired'"
),
{"past": datetime.now(UTC) - timedelta(days=1)},
)
session.commit()
response = create_posting(client, "emp-expired")
assert response.status_code == 402
# A purchase exists but is expired → the exhausted arm (both
# arms are honest 402 + upgrade_cta; the reason reflects the
# row's existence — MH-4d pins the 402 degrade, not the label).
assert response.json()["detail"]["reason"] == "posting_limit_reached"
assert "upgrade_cta" in response.json()["detail"]
def test_admin_gate_still_first(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""REQ-9-004: enforcement composes AFTER require_admin — no
token → 403, never a billing decision."""
response = client.post(
"/v1/marketplace/admin/postings",
json={"employer_id": "emp-broke", "title": "T"},
)
assert response.status_code == 403
def test_missing_billing_store_fails_closed(
self, tmp_path: Path, billing_store: SQLiteBillingStore
) -> None:
"""Final-review regression (fail-closed gate): a wiring miss
(no billing_store on app.state) must REFUSE posting creation
(503 with the fix pointer) — never silently skip the
entitlement check and return an unbilled 201. Simulated by
deleting the lifespan-wired attribute (the old getattr+hasattr
soft-guard made this exact state an ungated 201)."""
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=SecretStr(ADMIN_TOKEN),
)
app = create_app(settings)
with TestClient(app) as c:
# The wiring miss: the store is gone mid-flight (None — the
# exact state the old getattr+hasattr guard silently
# skipped). Restored before shutdown so the lifespan's
# close() stays intact — the route's guard is the subject.
real_store = app.state.billing_store
app.state.billing_store = None
try:
response = c.post(
"/v1/marketplace/admin/postings",
headers=admin_headers(),
json={"employer_id": "emp-unwired", "title": "T"},
)
finally:
app.state.billing_store = real_store
assert response.status_code == 503
assert "unavailable" in response.json()["detail"]
def test_seeded_ingest_never_gated(
self, tmp_path: Path, billing_store: SQLiteBillingStore
) -> None:
"""G-309: the seeded-corpus ingest upserts directly through the
store — zero purchases, zero 402s, zero consumption rows."""
from ai_service.marketplace.ingest import SeededCorpusSource, run_ingest
from ai_service.marketplace.store import SQLiteMarketplaceStore
marketplace_store = SQLiteMarketplaceStore(
db_path=tmp_path / "marketplace.db"
)
run_ingest(SeededCorpusSource(), marketplace_store)
packages, _ = billing_store.list_catalog()
postings = marketplace_store.list_postings(limit=1000, offset=0)
assert postings[1] > 0 # the seeded corpus landed
# Zero consumption rows exist.
from sqlalchemy import text
from sqlmodel import Session
with Session(billing_store._engine) as session:
count = int(
session.execute(
text("SELECT COUNT(*) FROM billing_purchases")
).scalar_one()
)
assert count == 0
marketplace_store.close()
class TestEntitlementsRoute:
def test_fail_closed(self, client: TestClient) -> None:
response = client.get("/v1/billing/admin/entitlements/emp-1")
assert response.status_code == 403
def test_shape(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
grant_posts(billing_store, "emp-view", 10)
response = client.get(
"/v1/billing/admin/entitlements/emp-view",
headers=admin_headers(),
)
assert response.status_code == 200
body = response.json()
assert body["employer_id"] == "emp-view"
assert body["purchase"]["posts_limit"] == 10
assert body["purchase"]["posts_used"] == 0
assert "expires_at" in body["purchase"]
assert body["subscription"] is None
@@ -0,0 +1,231 @@
"""D-091 payload-parity tests (REQ-10-004, MH-4a, R-58).
The shared gate's 402 wire contract is BYTE-STABLE: {reason,
posts_used: 0, posts_limit: 0, upgrade_cta} — the zeros are the v0.9
wire shape the web client's UpgradeRequiredError ignores (it consumes
reason + upgrade_cta only, apps/web/lib/engine-client.ts), never live
counts. These tests pin the exact dict through the admin route (the
v0.9 caller) and prove the principal parameterization: only the
upgrade_cta field carries the caller's checkout pointer — reason and
the hardcoded zeros are identical across principals. The employer
route (Task 4-1-02) will compose the SAME helper, extending this file
with the employer-gate arm.
Fixtures mirror tests/billing/test_entitlement_enforcement.py (the
R-64 state-injection make_client + grant_posts precedent) — the
enforcement suite itself stays UNCHANGED (the no-drift proof).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.api.entitlement import consume_posting_entitlement
from ai_service.billing.store import SQLiteBillingStore
from ai_service.config import Settings
from ai_service.main import create_app
from ..conftest import SUITE_LEARNERS
ADMIN_TOKEN = "test-parity-admin"
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
def make_client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> TestClient:
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=SecretStr(ADMIN_TOKEN),
)
app = create_app(settings)
app.state.billing_store = billing_store # state-injection (G-9)
return TestClient(app)
@pytest.fixture()
def client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[TestClient]:
with make_client(tmp_path, billing_store) as c:
yield c
def create_posting(client: TestClient, employer: str):
return client.post(
"/v1/marketplace/admin/postings",
headers={"X-Admin-Token": ADMIN_TOKEN},
json={"employer_id": employer, "title": "Parity Test Role"},
)
def grant_posts(
billing_store: SQLiteBillingStore, employer: str, limit: int | None
) -> None:
"""A direct purchase grant (the store path the webhook would take;
the test_entitlement_enforcement.py:70-85 precedent)."""
session_row = billing_store.insert_checkout_session(
provider_session_id=f"cs_parity_{employer}",
employer_id=employer,
item_id="pkg-single" if limit == 1 else "pkg-bundle",
item_name="Test Grant",
price_cents=0,
mode="payment",
)
billing_store.create_purchase_from_session(
session_row, posts_limit=limit, duration_days=30
)
class TestPayloadParity:
def test_no_package_402_exact_dict(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4a parity: no package → the EXACT canonical 402 dict —
hardcoded posts_used/posts_limit zeros (R-58), admin CTA."""
response = create_posting(client, "emp-parity-none")
assert response.status_code == 402
assert response.json()["detail"] == {
"reason": "no_active_package",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer has no active posting package — "
"purchase one via /v1/billing/admin/checkout."
),
}
def test_exhausted_402_exact_dict(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4a parity (exhausted arm): the EXACT canonical dict — the
zeros stay byte-stable even though the employer has a REAL
package with real counts (posts_used=1, posts_limit=1 in the
store; the payload never carries live limits)."""
grant_posts(billing_store, "emp-parity-spent", 1)
first = create_posting(client, "emp-parity-spent")
assert first.status_code == 201
second = create_posting(client, "emp-parity-spent")
assert second.status_code == 402
detail = second.json()["detail"]
assert detail == {
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer's posting package is exhausted — "
"buy more posts or upgrade via /v1/billing/admin/checkout."
),
}
# The store's truth differs from the payload's zeros — the
# wire contract is NOT a limits view (R-58 pin). The exhausted
# row is invisible to get_entitlement (no active purchase
# left), so the raw row read carries the truth (the
# test_expired_degrades_to_402 engine-session precedent).
from sqlalchemy import text
from sqlmodel import Session
with Session(billing_store._engine) as session:
row = session.execute(
text(
"SELECT posts_used, posts_limit FROM billing_purchases "
"WHERE employer_id = 'emp-parity-spent'"
)
).one()
assert tuple(row) == (1, 1)
def test_402_consumes_nothing_creates_nothing(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""R-58 gate-before-insert: a 402 consumes nothing (posts_used
unchanged) and creates nothing (no marketplace row) — the gate
runs BEFORE the insert."""
grant_posts(billing_store, "emp-parity-gate", 1)
assert create_posting(client, "emp-parity-gate").status_code == 201
marketplace_store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
before = marketplace_store.list_postings_by_employer(
"emp-parity-gate"
)
assert len(before) == 1
denied = create_posting(client, "emp-parity-gate")
assert denied.status_code == 402
# Nothing consumed: posts_used still 1 (the failed create spent
# nothing — the exception aborted before any second UPDATE; the
# row is now exhausted → invisible to get_entitlement, so the
# raw row read carries the truth).
from sqlalchemy import text
from sqlmodel import Session
with Session(billing_store._engine) as session:
posts_used = session.execute(
text(
"SELECT posts_used FROM billing_purchases "
"WHERE employer_id = 'emp-parity-gate'"
)
).scalar_one()
assert posts_used == 1
# Nothing created: the failed create inserted no marketplace row.
after = marketplace_store.list_postings_by_employer(
"emp-parity-gate"
)
assert len(after) == 1
assert [p.id for p in after] == [p.id for p in before]
def test_only_cta_differs_per_principal(
self, billing_store: SQLiteBillingStore
) -> None:
"""The D-091 parity core: the SAME store state through the
shared helper with two principals' CTAs → reason/posts_used/
posts_limit identical, ONLY upgrade_cta differs (the employer
route, Task 4-1-02, composes this same arm in-route)."""
from fastapi import HTTPException
payloads: list[dict[str, object]] = []
for cta in (
"/v1/billing/admin/checkout",
"/v1/employers/billing/checkout",
):
with pytest.raises(HTTPException) as exc_info:
consume_posting_entitlement(
billing_store, "emp-parity-principal", cta
)
assert exc_info.value.status_code == 402
payloads.append(dict(exc_info.value.detail)) # type: ignore[arg-type]
admin_payload, employer_payload = payloads
assert admin_payload["reason"] == employer_payload["reason"]
assert admin_payload["posts_used"] == 0
assert admin_payload["posts_limit"] == 0
assert employer_payload["posts_used"] == 0
assert employer_payload["posts_limit"] == 0
assert admin_payload["upgrade_cta"] != employer_payload["upgrade_cta"]
assert "/v1/billing/admin/checkout" in str(admin_payload["upgrade_cta"])
assert "/v1/employers/billing/checkout" in str(
employer_payload["upgrade_cta"]
)
def test_store_missing_fails_closed(
self, tmp_path: Path, billing_store: SQLiteBillingStore
) -> None:
"""The fail-closed 503 guard rides along with the extraction:
a wiring miss (None store) refuses creation — never an ungated
201 (the final-review fix, preserved in the helper)."""
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
consume_posting_entitlement(None, "emp-parity-unwired", "x")
assert exc_info.value.status_code == 503
assert "unavailable" in str(exc_info.value.detail)
@@ -0,0 +1,139 @@
"""Factory + settings trio tests (Task 1-1-05, R-53, D-075/D-077).
Pins (MH-1d):
- factory: mock default; explicit mock; unknown name → actionable
UnknownPaymentProviderError; 'stripe' without its config → the
actionable UnknownPaymentProviderError main.py catches (G-11)
- webhook secret resolution: configured secret is used VERBATIM (the
mint/verify round-trip proves the provider holds it); default empty
→ per-boot random (two providers do not accept each other's
signatures — fail-closed, D-077)
- settings redaction: payment_api_key is SecretStr — repr(Settings)
NEVER leaks the key material (the admin-token/embedding precedent)
"""
from __future__ import annotations
import pytest
from pydantic import SecretStr
from ai_service.billing.base import (
UnknownPaymentProviderError,
WebhookSignatureError,
)
from ai_service.billing.factory import payment_provider_from_settings
from ai_service.billing.mock import MockPaymentProvider
from ai_service.config import Settings
class TestFactorySelection:
def test_default_is_mock(self) -> None:
# D-075: mock default — the no-key pilot path is first-class.
provider = payment_provider_from_settings(Settings())
assert isinstance(provider, MockPaymentProvider)
def test_explicit_mock_selected(self) -> None:
provider = payment_provider_from_settings(
Settings(payment_provider="mock")
)
assert isinstance(provider, MockPaymentProvider)
def test_provider_name_normalized(self) -> None:
# Voice/embedding factory precedent: strip + lower — env typos
# in casing/whitespace must not 500 the selection.
provider = payment_provider_from_settings(
Settings(payment_provider=" MOCK ")
)
assert isinstance(provider, MockPaymentProvider)
def test_unknown_provider_raises(self) -> None:
with pytest.raises(UnknownPaymentProviderError, match="satellite"):
payment_provider_from_settings(Settings(payment_provider="satellite"))
def test_stripe_without_config_raises_actionable(self) -> None:
# P3: stripe is REAL now — but needs its key (R-53); the
# actionable error is the one main.py's lifespan catches → G-11
# mock fallback. Byte-contract behavior lives in
# test_stripe_provider.py (MockTransport, D-075).
with pytest.raises(UnknownPaymentProviderError, match="AI_PAYMENT_API_KEY"):
payment_provider_from_settings(Settings(payment_provider="stripe"))
def test_stripe_without_http_client_raises(self) -> None:
settings = Settings(
payment_provider="stripe",
payment_api_key=SecretStr("sk_test_x"),
webhook_secret="whsec_test",
public_base_url="https://nextcraft.example",
)
with pytest.raises(UnknownPaymentProviderError, match="httpx pool"):
payment_provider_from_settings(settings)
class TestWebhookSecretResolution:
def test_configured_secret_used_verbatim(self) -> None:
# The mint/verify round-trip proves the provider was built with
# the CONFIGURED secret (not a random one). mint_event is a
# mock-only helper (D-082's signed-delivery mint) — narrow the
# factory's protocol return to the concrete mock first.
provider = payment_provider_from_settings(
Settings(webhook_secret="whsec_test_configured")
)
assert isinstance(provider, MockPaymentProvider)
raw_body, header, _ = provider.mint_event("invoice.paid", {})
verified = provider.verify_webhook(raw_body, header)
assert verified.event.name == "invoice.paid"
def test_unset_secret_is_per_boot_random_fail_closed(self) -> None:
# D-077 (telemetry_ingest_secret pattern): unset → per-boot
# random. Two independently built providers hold DIFFERENT
# random secrets — one cannot verify the other's signatures,
# so an unconfigured deployment fails closed (401s), never
# processes unsigned traffic.
a = payment_provider_from_settings(Settings())
b = payment_provider_from_settings(Settings())
assert isinstance(a, MockPaymentProvider)
assert isinstance(b, MockPaymentProvider)
raw_body, header, _ = a.mint_event("invoice.paid", {})
with pytest.raises(WebhookSignatureError):
b.verify_webhook(raw_body, header)
class TestSettingsTrio:
def test_settings_defaults(self) -> None:
settings = Settings()
assert settings.payment_provider == "mock"
assert settings.payment_api_key.get_secret_value() == ""
assert settings.webhook_secret == ""
def test_env_prefix_wiring(self) -> None:
# R-53: the env trio is AI_PAYMENT_PROVIDER / AI_PAYMENT_API_KEY
# / AI_WEBHOOK_SECRET (pydantic-settings prefix AI_).
settings = Settings(
payment_provider="stripe",
payment_api_key=SecretStr("sk_test_placeholder"),
webhook_secret="whsec_placeholder",
)
assert settings.payment_provider == "stripe"
assert settings.payment_api_key.get_secret_value() == "sk_test_placeholder"
assert settings.webhook_secret == "whsec_placeholder"
def test_repr_does_not_leak_payment_api_key(self) -> None:
# SecretStr redaction: key material must never survive a repr
# (logs, error messages, tracebacks). 'sk_' is the leak canary.
settings = Settings(payment_api_key=SecretStr("sk_test_super_secret_key"))
rendered = repr(settings)
assert "sk_test_super_secret_key" not in rendered
assert "sk_" not in rendered
def test_str_does_not_leak_payment_api_key(self) -> None:
settings = Settings(payment_api_key=SecretStr("sk_test_super_secret_key"))
assert "sk_test_super_secret_key" not in str(settings)
assert "sk_" not in str(settings)
def test_webhook_secret_is_plain_str_by_design(self) -> None:
# The webhook secret is compared in-process (HMAC key), not
# echoed to surfaces — plain str mirrors telemetry_ingest_secret
# (SecretStr adds friction for a value that must be readable by
# the signer; it is never logged either way).
settings = Settings(webhook_secret="whsec_plain_ok")
assert settings.webhook_secret == "whsec_plain_ok"
@@ -0,0 +1,360 @@
"""Subscription lifecycle + admin routes tests (REQ-9-005, MH-5a..5d).
The transition matrix lives in the store (P1, test_store.py — the
matrix + lazy grace are pinned there). These tests pin the ROUTE
surface: cancel/resume/upgrade composition, manual grant/revoke
provenance, the queryable event log, and renewal idempotency through
the real webhook path.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.billing.mock import MockPaymentProvider
from ai_service.billing.store import SQLiteBillingStore
from ai_service.config import Settings
from ai_service.main import create_app
from ..conftest import SUITE_LEARNERS
ADMIN_TOKEN = "test-lifecycle-admin"
SECRET = "whsec_test_lifecycle"
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
def make_client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> TestClient:
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=SecretStr(ADMIN_TOKEN),
webhook_secret=SECRET,
)
app = create_app(settings)
app.state.billing_store = billing_store
app.state.payment_provider = MockPaymentProvider(webhook_secret=SECRET)
return TestClient(app)
@pytest.fixture()
def client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[TestClient]:
with make_client(tmp_path, billing_store) as c:
yield c
def admin_headers() -> dict[str, str]:
return {"X-Admin-Token": ADMIN_TOKEN}
def seed_subscription(
client: TestClient, billing_store: SQLiteBillingStore, employer: str
) -> str:
"""The honest grant path: admin checkout → admin complete → the
REAL webhook path creates the active subscription."""
checkout = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "plan-pro", "employer_id": employer},
headers=admin_headers(),
)
assert checkout.status_code == 201
provider_session_id = checkout.json()["provider_session_id"]
complete = client.post(
"/v1/billing/admin/mock/complete",
json={"provider_session_id": provider_session_id},
headers=admin_headers(),
)
assert complete.status_code == 200
entitlement = billing_store.get_entitlement(employer)
assert entitlement["subscription"] is not None
return str(entitlement["subscription"]["id"])
class TestLifecycleRoutes:
def test_all_fail_closed(self, client: TestClient) -> None:
for method, path in (
("post", "/v1/billing/admin/subscriptions/sub-1/cancel"),
("post", "/v1/billing/admin/subscriptions/sub-1/resume"),
("post", "/v1/billing/admin/subscriptions/sub-1/upgrade"),
("post", "/v1/billing/admin/employers/emp-1/grant"),
("post", "/v1/billing/admin/employers/emp-1/revoke"),
("get", "/v1/billing/admin/events"),
):
response = getattr(client, method)(path)
assert response.status_code == 403, f"{method} {path}"
def test_cancel_writes_provenance(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
subscription_id = seed_subscription(client, billing_store, "emp-cancel")
response = client.post(
f"/v1/billing/admin/subscriptions/{subscription_id}/cancel",
headers=admin_headers(),
)
assert response.status_code == 200
assert response.json()["status"] == "cancelled"
events = billing_store.list_billing_events(
employer_id="emp-cancel", kind="manual"
)
assert any(e.name == "cancel" for e in events)
def test_store_ref_matches_the_mock_provider(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""Final-review regression (ref desync): the subscription row's
provider_subscription_id must be a key the MOCK PROVIDER knows
(its documented sub_mock_ convention) — the old sub_prov_
spelling meant provider.cancel_subscription resolved None and
local state desynced from provider state on every lifecycle
call."""
seed_subscription(client, billing_store, "emp-ref")
subscription = billing_store.get_subscription(
str(billing_store.get_entitlement("emp-ref")["subscription"]["id"])
)
assert subscription is not None
provider = client.app.state.payment_provider # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
assert isinstance(provider, MockPaymentProvider)
assert (
subscription.provider_subscription_id in provider._subscriptions
), "the store's provider ref must resolve at the provider"
# The cancel route reaches the provider (not cancel:unknown).
response = client.post(
f"/v1/billing/admin/subscriptions/{subscription.id}/cancel",
headers=admin_headers(),
)
assert response.status_code == 200
assert response.json()["provider_action"] == "cancel:cancelled"
def test_unknown_subscription_404(self, client: TestClient) -> None:
response = client.post(
"/v1/billing/admin/subscriptions/sub-ghost/cancel",
headers=admin_headers(),
)
assert response.status_code == 404
def test_resume_illegal_transition_409(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""active → active is outside the matrix — the honest 409."""
subscription_id = seed_subscription(client, billing_store, "emp-resume")
response = client.post(
f"/v1/billing/admin/subscriptions/{subscription_id}/resume",
headers=admin_headers(),
)
assert response.status_code == 409
def test_upgrade_keeps_active_changes_plan(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""G-305: upgrade = plan_id transition within active; the manual
billing_event carries provenance."""
subscription_id = seed_subscription(client, billing_store, "emp-up")
response = client.post(
f"/v1/billing/admin/subscriptions/{subscription_id}/upgrade",
json={"plan_id": "plan-starter"},
headers=admin_headers(),
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "active" # G-305: status unchanged
entitlement = billing_store.get_entitlement("emp-up")
assert entitlement["subscription"]["plan_id"] == "plan-starter"
events = billing_store.list_billing_events(
employer_id="emp-up", kind="manual"
)
assert any(e.name == "upgrade" for e in events)
def test_upgrade_unknown_plan_404(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
subscription_id = seed_subscription(client, billing_store, "emp-up2")
response = client.post(
f"/v1/billing/admin/subscriptions/{subscription_id}/upgrade",
json={"plan_id": "plan-nope"},
headers=admin_headers(),
)
assert response.status_code == 404
class TestManualGrantRevoke:
def test_grant_unlimited_records_provenance(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
response = client.post(
"/v1/billing/admin/employers/emp-grant/grant",
headers=admin_headers(),
)
assert response.status_code == 201
entitlement = billing_store.get_entitlement("emp-grant")
assert entitlement["purchase"] is not None
assert entitlement["purchase"]["posts_limit"] is None # unlimited
events = billing_store.list_billing_events(
employer_id="emp-grant", kind="manual"
)
assert any(e.name == "grant" and e.operator for e in events)
def test_grant_twice_never_500s(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""Final-review regression: a repeated manual grant for the SAME
employer must never collide on the provider_session_id UNIQUE
constraint (the old cs_manual_{employer_id} placeholder 500'd
on the second grant; remediation is a repeat operation)."""
first = client.post(
"/v1/billing/admin/employers/emp-twice/grant",
headers=admin_headers(),
)
second = client.post(
"/v1/billing/admin/employers/emp-twice/grant",
headers=admin_headers(),
)
assert first.status_code == 201, first.text
assert second.status_code == 201, second.text
entitlement = billing_store.get_entitlement("emp-twice")
assert entitlement["purchase"] is not None
assert entitlement["purchase"]["posts_limit"] is None
def test_revoke_expires_grants(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
client.post(
"/v1/billing/admin/employers/emp-grant2/grant",
headers=admin_headers(),
)
response = client.post(
"/v1/billing/admin/employers/emp-grant2/revoke",
headers=admin_headers(),
)
assert response.status_code == 200
entitlement = billing_store.get_entitlement("emp-grant2")
assert entitlement["purchase"] is None # expired → no active grant
events = billing_store.list_billing_events(
employer_id="emp-grant2", kind="manual"
)
assert any(e.name == "revoke" for e in events)
class TestEventLog:
def test_events_filterable(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
seed_subscription(client, billing_store, "emp-log")
client.post(
"/v1/billing/admin/employers/emp-log/grant", headers=admin_headers()
)
# MH-5d: filter by employer
response = client.get(
"/v1/billing/admin/events",
params={"employer_id": "emp-log"},
headers=admin_headers(),
)
assert response.status_code == 200
body = response.json()
assert body["count"] > 0
assert all(
e["employer_id"] == "emp-log" for e in body["events"]
)
# Filter by kind narrows further.
manual = client.get(
"/v1/billing/admin/events",
params={"employer_id": "emp-log", "kind": "manual"},
headers=admin_headers(),
).json()
assert all(e["kind"] == "manual" for e in manual["events"])
assert any(e["operator"] for e in manual["events"])
def test_events_include_provider_and_unmatched(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""The log answers 'what happened?' with full provenance —
provider events + unmatched deliveries both surface."""
provider = client.app.state.payment_provider # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
assert isinstance(provider, MockPaymentProvider)
raw, header, _ = provider.mint_event(
"checkout.completed", {"session_id": "cs_ghost"}
)
client.post(
"/v1/billing/webhook",
content=raw,
headers={"Stripe-Signature": header},
)
response = client.get(
"/v1/billing/admin/events",
params={"kind": "unmatched"},
headers=admin_headers(),
)
assert response.status_code == 200
assert response.json()["count"] >= 1
class TestRenewalIdempotency:
def test_double_renewal_single_extension(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-5b: the SAME provider event delivered twice extends the
period exactly once (event-id dedupe). A DISTINCT renewal event
extends again — that is honest billing."""
subscription_id = seed_subscription(client, billing_store, "emp-renew")
subscription = billing_store.get_subscription(subscription_id)
assert subscription is not None
provider = client.app.state.payment_provider # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
assert isinstance(provider, MockPaymentProvider)
# Same event id — delivered twice (R-45 at-least-once).
raw, header, _ = provider.mint_event(
"invoice.paid", {"subscription_id": subscription.provider_subscription_id}
)
first = client.post(
"/v1/billing/webhook", content=raw, headers={"Stripe-Signature": header}
)
second = client.post(
"/v1/billing/webhook", content=raw, headers={"Stripe-Signature": header}
)
assert first.status_code == 200
assert second.status_code == 200 # duplicate → 200, no re-extension
after_first = billing_store.get_subscription(subscription_id)
assert after_first is not None
period_after_dupes = after_first.current_period_end
# A DISTINCT renewal event extends once more.
raw2, header2, _ = provider.mint_event(
"invoice.paid", {"subscription_id": subscription.provider_subscription_id}
)
client.post(
"/v1/billing/webhook", content=raw2, headers={"Stripe-Signature": header2}
)
after_second = billing_store.get_subscription(subscription_id)
assert after_second is not None
assert period_after_dupes is not None
assert after_second.current_period_end == period_after_dupes + _thirty_days()
# subscription.renewed maps onto the same idempotent path (R-43).
raw3, header3, _ = provider.mint_event(
"subscription.renewed",
{"subscription_id": subscription.provider_subscription_id},
)
client.post(
"/v1/billing/webhook", content=raw3, headers={"Stripe-Signature": header3}
)
after_third = billing_store.get_subscription(subscription_id)
assert after_third is not None
assert after_third.current_period_end == period_after_dupes + _thirty_days() * 2
def _thirty_days():
from datetime import timedelta
return timedelta(days=30)
+219
View File
@@ -0,0 +1,219 @@
"""Mock provider tests (Task 1-1-03, D-075, REQ-9-001).
Pins:
- session ids are UNIQUE per create call across 100 calls (the
identity-mock unique-mint lesson — a deterministic id would collide
on retries and conflate distinct logical checkouts, G-303)
- ids are Stripe-shaped with the mock marker: cs_mock_... (R-36)
- session lifecycle: create → open/unpaid; confirm_completion →
complete/paid (the R-37 grant predicate, scriptable not faked);
unknown/already-complete confirm raises (fail loud — the ROUTE owns
idempotency via event dedupe)
- mint_event → verify_webhook round-trip green (the mock-complete path
D-082 posts through the REAL verification scheme)
- verify_webhook fails closed on tampered bytes + wrong secret
- normalize_event is a passthrough that still enforces the canonical
enum (R-43: the mock speaks OUR names directly)
- retrieve unknown id → None; subscriptions cancel per R-46
"""
from __future__ import annotations
from typing import Any
import pytest
from ai_service.billing.base import (
CheckoutSessionSpec,
PaymentProvider,
WebhookSignatureError,
)
from ai_service.billing.mock import MockPaymentProvider
SECRET = "whsec_mock_test_secret"
def _spec(**overrides: Any) -> CheckoutSessionSpec:
base: dict[str, Any] = {
"mode": "payment",
"item_id": "pkg-single",
"price_cents": 4900,
"item_name": "Single Post",
"interval": "",
"client_reference_id": "sess_local_1",
"metadata": {"employer_id": "E1"},
}
base.update(overrides)
return CheckoutSessionSpec(**base)
@pytest.fixture
def provider() -> MockPaymentProvider:
return MockPaymentProvider(webhook_secret=SECRET)
class TestIdUniqueness:
async def test_ids_unique_across_100_calls(self) -> None:
# The unique-mint lesson: per-process nonce + monotonic counter
# — uniqueness per call is the contract.
provider = MockPaymentProvider()
ids = {
(await provider.create_checkout_session(_spec())).id
for _ in range(100)
}
assert len(ids) == 100
def test_ids_unique_across_instances(self) -> None:
# Two providers on one box (e.g. multi-worker) never mint the
# same session id: the nonce is per-instance.
a = MockPaymentProvider()._mint_session_id()
b = MockPaymentProvider()._mint_session_id()
assert a != b
async def test_id_shape_is_stripe_mock(self) -> None:
provider = MockPaymentProvider()
session = await provider.create_checkout_session(_spec())
assert session.id.startswith("cs_mock_")
assert len(session.id) > len("cs_mock_")
class TestSessionLifecycle:
async def test_create_is_open_unpaid(self, provider: MockPaymentProvider) -> None:
session = await provider.create_checkout_session(_spec())
assert session.status == "open"
assert session.payment_status == "unpaid"
assert session.mode == "payment"
assert session.expires_at is not None
async def test_confirm_flips_complete_paid(
self, provider: MockPaymentProvider
) -> None:
session = await provider.create_checkout_session(_spec())
completed = await provider.confirm_completion(session.id)
assert completed.status == "complete"
assert completed.payment_status == "paid" # R-37 predicate
async def test_retrieve_reflects_completion(
self, provider: MockPaymentProvider
) -> None:
session = await provider.create_checkout_session(_spec())
await provider.confirm_completion(session.id)
retrieved = await provider.retrieve_session(session.id)
assert retrieved is not None
assert retrieved.status == "complete"
assert retrieved.payment_status == "paid"
async def test_confirm_unknown_session_raises(
self, provider: MockPaymentProvider
) -> None:
with pytest.raises(ValueError, match="unknown checkout session"):
await provider.confirm_completion("cs_mock_does_not_exist")
async def test_confirm_twice_raises(
self, provider: MockPaymentProvider
) -> None:
# Fail loud, not idempotent-silent: the ROUTE owns idempotency
# (event dedupe + G-303 per-session grants), not the provider.
session = await provider.create_checkout_session(_spec())
await provider.confirm_completion(session.id)
with pytest.raises(ValueError, match="not open"):
await provider.confirm_completion(session.id)
async def test_retrieve_unknown_session_none(
self, provider: MockPaymentProvider
) -> None:
assert await provider.retrieve_session("cs_mock_nope") is None
class TestSubscriptionMode:
async def test_subscription_completion_creates_active_ref(
self, provider: MockPaymentProvider
) -> None:
session = await provider.create_checkout_session(
_spec(mode="subscription", item_id="plan-pro")
)
await provider.confirm_completion(session.id)
cancelled = await provider.cancel_subscription(
f"sub_mock_{session.id.removeprefix('cs_')}"
)
assert cancelled is not None
assert cancelled.status == "cancelled"
async def test_cancel_unknown_subscription_none(
self, provider: MockPaymentProvider
) -> None:
assert await provider.cancel_subscription("sub_mock_nope") is None
class TestWebhookRoundTrip:
async def test_mint_event_verify_roundtrip_green(
self, provider: MockPaymentProvider
) -> None:
# The D-082 path: mint_event produces exactly what a provider
# delivery looks like; verify_webhook (the REAL shared scheme)
# authenticates + parses it.
raw_body, header, event_id = provider.mint_event(
"checkout.completed", {"session_id": "cs_mock_x"}
)
verified = provider.verify_webhook(raw_body, header)
assert verified.event.name == "checkout.completed"
assert verified.event.payload == {"session_id": "cs_mock_x"}
assert verified.provider_event_id == event_id
async def test_event_ids_unique_across_mints(
self, provider: MockPaymentProvider
) -> None:
ids = {
provider.mint_event("invoice.paid", {})[2] for _ in range(50)
}
assert len(ids) == 50
def test_verify_tampered_body_fails_closed(
self, provider: MockPaymentProvider
) -> None:
raw_body, header, _ = provider.mint_event("invoice.paid", {})
with pytest.raises(WebhookSignatureError):
provider.verify_webhook(raw_body + b"x", header)
def test_verify_wrong_secret_fails_closed(self) -> None:
other = MockPaymentProvider(webhook_secret="whsec_other")
raw_body, header, _ = other.mint_event("invoice.paid", {})
provider = MockPaymentProvider(webhook_secret=SECRET)
with pytest.raises(WebhookSignatureError):
provider.verify_webhook(raw_body, header)
def test_verify_missing_header_fails_closed(
self, provider: MockPaymentProvider
) -> None:
with pytest.raises(WebhookSignatureError, match="missing"):
provider.verify_webhook(b"{}", "")
class TestNormalizePassthrough:
def test_canonical_names_pass_through(self, provider: MockPaymentProvider) -> None:
# R-43: the mock speaks canonical names directly — no mapping.
event = provider.normalize_event(
{"name": "subscription.renewed", "payload": {"subscription_id": "s1"}}
)
assert event.name == "subscription.renewed"
assert event.payload == {"subscription_id": "s1"}
def test_vendor_name_rejected(self, provider: MockPaymentProvider) -> None:
# Passthrough still enforces the closed enum: a vendor-shaped
# name must not silently validate as canonical.
with pytest.raises(ValueError, match="unknown event name"):
provider.normalize_event({"name": "checkout.session.completed"})
def test_payload_non_dict_degrades_to_empty(
self, provider: MockPaymentProvider
) -> None:
event = provider.normalize_event({"name": "invoice.paid", "payload": "oops"})
assert event.payload == {}
class TestProtocolConformance:
def test_satisfies_payment_provider_protocol(self) -> None:
# runtime_checkable protocol: the mock IS a PaymentProvider
# (structural) — the seam P3's stripe provider must also satisfy.
provider = MockPaymentProvider()
assert isinstance(provider, PaymentProvider)
+578
View File
@@ -0,0 +1,578 @@
"""BillingStore tests — the 7th D-027 store (REQ-9-001).
Mirrors tests/marketplace/test_store.py: tmp_path db, sqlite_master
table-existence pins, PRAGMA pins, IntegrityError surfacing, snapshot
integrity, dedupe idempotency, transition matrix, and the grill
guards: G-303 (per-session grants), G-304 (conditional-UPDATE
consumption), G-305 (no upgraded status), G-306 (lazy grace expiry).
"""
from __future__ import annotations
import threading
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session
from ai_service.billing.base import Package, Plan
from ai_service.billing.store import (
PAST_DUE_GRACE_DAYS,
CheckoutSessionRow,
InvalidTransition,
NoActivePackage,
PackageExhausted,
SQLiteBillingStore,
)
SEED_PACKAGES = [
Package(
id="pkg-single",
name="Single Post",
price_cents=4900,
posts_limit=1,
duration_days=30,
features=["1 posting", "30 days"],
),
Package(
id="pkg-bundle",
name="Bundle",
price_cents=39900,
posts_limit=10,
duration_days=90,
features=["10 postings", "90 days each"],
),
]
SEED_PLANS = [
Plan(
id="plan-starter",
name="Starter",
price_cents=9900,
interval="month",
features=["browse talent"],
),
Plan(
id="plan-pro",
name="Pro",
price_cents=29900,
interval="month",
features=["unlimited views"],
),
]
def make_store(tmp_path: Path) -> SQLiteBillingStore:
return SQLiteBillingStore(db_path=tmp_path / "billing-test.db")
def seeded_store(tmp_path: Path) -> SQLiteBillingStore:
store = make_store(tmp_path)
store.seed_catalog(SEED_PACKAGES, SEED_PLANS)
return store
def make_session_row(
store: SQLiteBillingStore, employer: str = "emp-1"
) -> CheckoutSessionRow:
return store.insert_checkout_session(
provider_session_id=f"cs_mock_{employer}",
employer_id=employer,
item_id="pkg-bundle",
item_name="Bundle",
price_cents=39900,
mode="payment",
)
class TestSchema:
def test_all_seven_tables_exist(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
with store._engine.connect() as conn:
names = {
row[0]
for row in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
}
for table in (
"billing_packages",
"billing_plans",
"billing_purchases",
"billing_subscriptions",
"billing_provider_events",
"billing_checkout_sessions",
"billing_billing_events",
):
assert table in names
def test_pragmas(self, tmp_path: Path) -> None:
"""WAL + synchronous=NORMAL + busy_timeout + foreign_keys — the
D-027 per-connection contract."""
store = make_store(tmp_path)
with store._engine.connect() as conn:
journal_row = conn.execute(text("PRAGMA journal_mode")).fetchone()
sync_row = conn.execute(text("PRAGMA synchronous")).fetchone()
busy_row = conn.execute(text("PRAGMA busy_timeout")).fetchone()
fk_row = conn.execute(text("PRAGMA foreign_keys")).fetchone()
assert journal_row is not None and sync_row is not None
assert busy_row is not None and fk_row is not None
assert str(journal_row[0]).lower() == "wal"
assert sync_row[0] == 1 # NORMAL
assert busy_row[0] == 5000
assert fk_row[0] == 1
def test_seed_catalog_idempotent(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
store.seed_catalog(SEED_PACKAGES, SEED_PLANS) # second run
packages, plans = store.list_catalog()
assert [p.id for p in packages] == ["pkg-single", "pkg-bundle"]
assert [p.id for p in plans] == ["plan-starter", "plan-pro"]
class TestSnapshots:
def test_purchase_snapshot_survives_catalog_edit(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
session_row = make_session_row(store)
purchase = store.create_purchase_from_session(
session_row, posts_limit=10, duration_days=90
)
assert purchase.package_name == "Bundle"
assert purchase.price_cents == 39900
# Catalog price edit must NOT rewrite purchase history (D-078).
edited = [p.model_copy(update={"price_cents": 99900}) for p in SEED_PACKAGES]
store.seed_catalog(edited, SEED_PLANS)
packages, _ = store.list_catalog()
assert packages[1].price_cents == 99900
entitlement = store.get_entitlement("emp-1")
assert entitlement["purchase"]["posts_limit"] == 10
assert purchase.price_cents == 39900 # snapshot intact
class TestProviderEventDedupe:
def test_same_event_id_replay_created_false(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert store.insert_provider_event("evt-1", "invoice.paid", {"x": 1}) is True
assert store.insert_provider_event("evt-1", "invoice.paid", {"x": 1}) is False
def test_distinct_event_ids_pass_through(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert store.insert_provider_event("evt-1", "invoice.paid", {}) is True
assert store.insert_provider_event("evt-2", "invoice.paid", {}) is True
class TestPerSessionGrants:
"""G-303: grants are idempotent per SESSION, not per event id."""
def test_second_purchase_for_same_session_raises(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
session_row = make_session_row(store)
store.create_purchase_from_session(
session_row, posts_limit=10, duration_days=90
)
with pytest.raises(IntegrityError):
store.create_purchase_from_session(
session_row, posts_limit=10, duration_days=90
)
def test_subscription_upsert_keyed_on_provider_ref(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
session_row = store.insert_checkout_session(
provider_session_id="cs_mock_sub_1",
employer_id="emp-1",
item_id="plan-pro",
item_name="Pro",
price_cents=29900,
mode="subscription",
)
first = store.create_subscription_from_session(
session_row,
provider_subscription_id="sub_prov_1",
interval="month",
)
second = store.create_subscription_from_session(
session_row,
provider_subscription_id="sub_prov_1",
interval="month",
)
assert first.id == second.id # the EXISTING row rides the replay
def test_distinct_event_ids_same_session_one_purchase(
self, tmp_path: Path
) -> None:
"""G-303 composite pin (Task 1-1-02's exact test): two provider
events with DIFFERENT event ids announcing the SAME session's
completion → exactly one purchase row. Event-id dedupe alone
would pass both through; the per-SESSION UniqueConstraint is
what makes the grant exactly-once."""
store = seeded_store(tmp_path)
session_row = make_session_row(store)
# Both distinct events are stored verbatim (dedupe keys differ)…
assert store.insert_provider_event("evt-a", "checkout.completed", {}) is True
assert store.insert_provider_event("evt-b", "checkout.completed", {}) is True
# …each handler attempt grants from the same session snapshot…
first = store.create_purchase_from_session(
session_row, posts_limit=10, duration_days=90
)
# …and the second grant for that session is refused by the
# constraint (IntegrityError → the caller records
# already-granted, never a second purchase row).
with pytest.raises(IntegrityError):
store.create_purchase_from_session(
session_row, posts_limit=10, duration_days=90
)
with Session(store._engine) as session:
count = int(
session.execute(
text(
"SELECT COUNT(*) FROM billing_purchases "
"WHERE employer_id = 'emp-1'"
)
).scalar_one()
)
assert count == 1 # one purchase despite two distinct event ids
assert first.posts_limit == 10
class TestConsumePosting:
def test_consume_increments(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
session_row = make_session_row(store)
store.create_purchase_from_session(
session_row, posts_limit=10, duration_days=90
)
row = store.consume_posting("emp-1")
assert row.posts_used == 1
def test_no_purchase_raises_no_active(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
with pytest.raises(NoActivePackage):
store.consume_posting("emp-none")
def test_exhausted_raises(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
session_row = store.insert_checkout_session(
provider_session_id="cs_mock_single",
employer_id="emp-1",
item_id="pkg-single",
item_name="Single Post",
price_cents=4900,
mode="payment",
)
store.create_purchase_from_session(
session_row, posts_limit=1, duration_days=30
)
assert store.consume_posting("emp-1").posts_used == 1
with pytest.raises(PackageExhausted):
store.consume_posting("emp-1")
def test_unlimited_never_exhausts(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
session_row = make_session_row(store)
purchase = store.create_purchase_from_session(
session_row, posts_limit=None, duration_days=None
)
assert purchase.posts_limit is None
for _ in range(5):
assert store.consume_posting("emp-1") is not None
def test_expired_purchase_skipped(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
session_row = make_session_row(store)
purchase = store.create_purchase_from_session(
session_row, posts_limit=10, duration_days=90
)
assert purchase.expires_at is not None
# Force-expire: created_at back far enough that expires_at passed.
with Session(store._engine) as session:
session.execute(
text(
"UPDATE billing_purchases SET expires_at = :past "
"WHERE id = :id"
),
{"past": datetime.now(UTC) - timedelta(days=1), "id": purchase.id},
)
session.commit()
with pytest.raises(PackageExhausted):
store.consume_posting("emp-1")
def test_newest_purchase_consumed_first(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
older = store.insert_checkout_session(
provider_session_id="cs_old",
employer_id="emp-1",
item_id="pkg-single",
item_name="Single Post",
price_cents=4900,
mode="payment",
)
store.create_purchase_from_session(
older, posts_limit=1, duration_days=30
)
with Session(store._engine) as session:
session.execute(
text(
"UPDATE billing_purchases SET created_at = :past WHERE id = "
"(SELECT id FROM billing_purchases WHERE checkout_session_id = "
"(SELECT id FROM billing_checkout_sessions WHERE "
"provider_session_id = 'cs_old'))"
),
{"past": datetime.now(UTC) - timedelta(days=10)},
)
session.commit()
newer = make_session_row(store) # cs_mock_emp-1, newest
store.create_purchase_from_session(
newer, posts_limit=10, duration_days=90
)
row = store.consume_posting("emp-1")
# The newest (bundle, 10 posts) must be the one consumed.
assert row.checkout_session_id == newer.id
assert row.package_id == "pkg-bundle"
def test_concurrent_consumes_one_post_left(self, tmp_path: Path) -> None:
"""G-304: the conditional UPDATE means two concurrent consumes
against ONE remaining post → exactly one succeeds. Deterministic:
both connections fire the same statement; SQLite WAL serializes
the write; the loser's rowcount is 0."""
store = seeded_store(tmp_path)
session_row = store.insert_checkout_session(
provider_session_id="cs_race",
employer_id="emp-race",
item_id="pkg-single",
item_name="Single Post",
price_cents=4900,
mode="payment",
)
store.create_purchase_from_session(
session_row, posts_limit=1, duration_days=30
)
results: list[str] = []
barrier = threading.Barrier(2)
def consume() -> None:
barrier.wait() # both attempt the same instant
try:
store.consume_posting("emp-race")
results.append("ok")
except (PackageExhausted, NoActivePackage):
results.append("exhausted")
threads = [threading.Thread(target=consume) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert sorted(results) == ["exhausted", "ok"] # exactly one win
with Session(store._engine) as session:
used = int(
session.execute(
text(
"SELECT posts_used FROM billing_purchases "
"WHERE employer_id = 'emp-race'"
)
).scalar_one()
)
assert used == 1 # single increment, no double-spend
class TestTransitionMatrix:
def _active_subscription(
self, store: SQLiteBillingStore, ref: str = "cs_sub"
) -> str:
session_row = store.insert_checkout_session(
provider_session_id=ref,
employer_id="emp-sub",
item_id="plan-pro",
item_name="Pro",
price_cents=29900,
mode="subscription",
)
sub = store.create_subscription_from_session(
session_row,
provider_subscription_id=f"sub_prov_{ref}",
interval="month",
)
return sub.id
def test_legal_transitions(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
sub_id = self._active_subscription(store)
assert store.transition_subscription(sub_id, "past_due") is not None
assert store.transition_subscription(sub_id, "active") is not None
assert store.transition_subscription(sub_id, "cancelled") is not None
def test_illegal_transition_raises(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
sub_id = self._active_subscription(store)
with pytest.raises(InvalidTransition):
store.transition_subscription(sub_id, "trialing")
def test_cancelled_is_terminal(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
sub_id = self._active_subscription(store)
store.transition_subscription(sub_id, "cancelled")
with pytest.raises(InvalidTransition):
store.transition_subscription(sub_id, "active")
def test_past_due_sets_timestamp(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
sub_id = self._active_subscription(store)
row = store.transition_subscription(sub_id, "past_due")
assert row is not None
assert row.past_due_at is not None
recovered = store.transition_subscription(sub_id, "active")
assert recovered is not None
assert recovered.past_due_at is None
def test_no_upgraded_status_value(self, tmp_path: Path) -> None:
"""G-305: the validated set contains no `upgraded`."""
store = seeded_store(tmp_path)
sub_id = self._active_subscription(store)
with pytest.raises(ValueError, match="invalid subscription status"):
store.transition_subscription(sub_id, "upgraded")
def test_past_due_can_cancel_and_expire(self, tmp_path: Path) -> None:
"""Final-review regression: past_due → cancelled (and → expired)
are legal matrix moves — the past_due_at timestamp must clear
(the _validate invariant binds it to past_due ONLY) or the
transition raises and the subscription is stuck forever."""
store = seeded_store(tmp_path)
sub_id = self._active_subscription(store)
store.transition_subscription(sub_id, "past_due")
cancelled = store.transition_subscription(sub_id, "cancelled")
assert cancelled is not None
assert cancelled.status == "cancelled"
assert cancelled.past_due_at is None
# The expired arm (matrix: past_due → expired) clears too.
sub2 = self._active_subscription(store, ref="cs_sub2")
store.transition_subscription(sub2, "past_due")
expired = store.transition_subscription(sub2, "expired")
assert expired is not None
assert expired.status == "expired"
assert expired.past_due_at is None
def test_upgrade_is_plan_transition_within_active(self, tmp_path: Path) -> None:
"""G-305: upgrade changes plan_id, keeps status=active, writes
a manual billing_event."""
store = seeded_store(tmp_path)
sub_id = self._active_subscription(store)
row = store.upgrade_subscription(sub_id, "plan-pro", "Pro", 29900)
assert row is not None
assert row.status == "active"
assert row.plan_id == "plan-pro"
events = store.list_billing_events(employer_id="emp-sub", kind="manual")
assert any(e.name == "upgrade" for e in events)
class TestLazyGraceExpiry:
"""G-306: past_due → expired computed LAZILY at read time."""
def _past_due(self, store: SQLiteBillingStore) -> None:
session_row = store.insert_checkout_session(
provider_session_id="cs_grace",
employer_id="emp-grace",
item_id="plan-pro",
item_name="Pro",
price_cents=29900,
mode="subscription",
)
sub = store.create_subscription_from_session(
session_row,
provider_subscription_id="sub_grace",
interval="month",
)
store.transition_subscription(sub.id, "past_due")
def _past_due_at(self, store: SQLiteBillingStore) -> datetime:
with Session(store._engine) as session:
raw = session.execute(
text(
"SELECT past_due_at FROM billing_subscriptions "
"WHERE employer_id = 'emp-grace'"
)
).scalar_one()
assert isinstance(raw, str) # raw text() returns ISO strings
return datetime.fromisoformat(raw).replace(tzinfo=UTC)
def test_inside_grace_still_granted(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
self._past_due(store)
past_due_at = self._past_due_at(store)
inside = past_due_at + timedelta(days=PAST_DUE_GRACE_DAYS - 1)
entitlement = store.get_entitlement("emp-grace", now=inside)
assert entitlement["subscription"]["status"] == "past_due"
def test_beyond_grace_reads_expired(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
self._past_due(store)
past_due_at = self._past_due_at(store)
beyond = past_due_at + timedelta(days=PAST_DUE_GRACE_DAYS + 1)
entitlement = store.get_entitlement("emp-grace", now=beyond)
assert entitlement["subscription"]["status"] == "expired"
# No writes happened — lazy evaluation (G-306).
with Session(store._engine) as session:
status = session.execute(
text(
"SELECT status FROM billing_subscriptions "
"WHERE employer_id = 'emp-grace'"
)
).scalar_one()
assert status == "past_due"
class TestRenewalExtension:
def test_extend_period_idempotent_per_event(self, tmp_path: Path) -> None:
store = seeded_store(tmp_path)
session_row = store.insert_checkout_session(
provider_session_id="cs_renew",
employer_id="emp-renew",
item_id="plan-pro",
item_name="Pro",
price_cents=29900,
mode="subscription",
)
sub = store.create_subscription_from_session(
session_row,
provider_subscription_id="sub_renew",
interval="month",
)
assert sub.current_period_end is not None
before = sub.current_period_end
extended = store.extend_subscription_period("sub_renew", days=30)
assert extended is not None
assert extended.current_period_end == before + timedelta(days=30)
class TestAuditLog:
def test_manual_provenance_round_trips(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
store.insert_billing_event(
"manual", "grant", employer_id="emp-1", operator="op-1"
)
rows = store.list_billing_events(employer_id="emp-1", kind="manual")
assert len(rows) == 1
assert rows[0].operator == "op-1"
def test_unmatched_marker_round_trips(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
store.insert_billing_event(
"unmatched", "checkout.completed", detail={"session": "cs_unknown"}
)
rows = store.list_billing_events(kind="unmatched")
assert len(rows) == 1
assert rows[0].name == "checkout.completed"
def test_filters_by_employer_and_kind(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
store.insert_billing_event("provider", "invoice.paid", employer_id="emp-1")
store.insert_billing_event("manual", "grant", employer_id="emp-2")
assert len(store.list_billing_events(employer_id="emp-1")) == 1
assert len(store.list_billing_events(kind="provider")) == 1
def test_invalid_kind_rejected(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
with pytest.raises(ValueError, match="invalid billing event kind"):
store.insert_billing_event("bogus", "x")
@@ -0,0 +1,328 @@
"""StripePaymentProvider byte-contract tests (Task 3-1-01, D-075).
httpx.MockTransport ONLY (the openai-audio/embedding pattern) — the
cloud is never called. Each handler asserts the EXACT wire shape:
form-encoded bodies with bracket nesting (R-33), Basic auth (R-33),
Idempotency-Key on POSTs (R-49), items[0][id] on updates (R-47 — the
dual-charge guard), success_url/cancel_url presence (G-310), and key
redaction in every raised error (A-305).
"""
from __future__ import annotations
import base64
import json
import urllib.parse
from typing import Any
import httpx
import pytest
from ai_service.billing.base import (
CheckoutSessionSpec,
UnknownPaymentProviderError,
)
from ai_service.billing.stripe import StripePaymentProvider
from ai_service.billing.webhooks import mint as mint_signature
KEY = "sk_test_bytecontract"
SECRET = "whsec_test_bytecontract"
def make_provider(handler) -> tuple[StripePaymentProvider, dict[str, Any]]:
seen: dict[str, Any] = {}
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
provider = StripePaymentProvider(
http_client=client,
api_key=KEY,
webhook_secret=SECRET,
)
return provider, seen
def form_body(request: httpx.Request) -> dict[str, str]:
return {
pair[0]: pair[1]
for pair in urllib.parse.parse_qsl(request.content.decode("utf-8"))
}
def spec(mode: str = "payment", interval: str = "") -> CheckoutSessionSpec:
return CheckoutSessionSpec(
mode=mode, # type: ignore[arg-type]
item_id="plan-pro" if mode == "subscription" else "pkg-bundle",
price_cents=39900,
item_name="Bundle" if mode == "payment" else "Pro",
interval=interval,
success_url="https://nextcraft.example/marketplace/pricing?checkout=success",
cancel_url="https://nextcraft.example/marketplace/pricing?checkout=cancelled",
client_reference_id="sess_local_1",
metadata={"employer_id": "emp-1", "operator_ref": "operator"},
)
@pytest.mark.asyncio
async def test_create_session_form_encoded_body_with_bracket_nesting():
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v1/checkout/sessions"
seen["body"] = form_body(request)
seen["auth"] = request.headers.get("authorization", "")
seen["idem"] = request.headers.get("idempotency-key", "")
return httpx.Response(
200,
json={
"id": "cs_test_abc",
"url": "https://checkout.stripe.com/pay/cs_test_abc",
"status": "open",
"payment_status": "unpaid",
"mode": "payment",
},
)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
provider = StripePaymentProvider(
http_client=client, api_key=KEY, webhook_secret=SECRET
)
session = await provider.create_checkout_session(spec())
assert session.id == "cs_test_abc"
assert session.status == "open"
# R-33: form-encoded, bracket nesting — NOT JSON.
assert seen["body"]["line_items[0][price_data][currency]"] == "usd"
assert seen["body"]["line_items[0][price_data][unit_amount]"] == "39900"
assert (
seen["body"]["line_items[0][price_data][product_data][name]"] == "Bundle"
)
assert seen["body"]["mode"] == "payment"
assert seen["body"]["client_reference_id"] == "sess_local_1"
assert seen["body"]["metadata[employer_id]"] == "emp-1"
# G-310: success/cancel URLs ride every create (R-34 required).
assert seen["body"]["success_url"].startswith("https://")
assert seen["body"]["cancel_url"].startswith("https://")
# R-33: Basic auth with the key as user, empty password.
expected = base64.b64encode(f"{KEY}:".encode()).decode()
assert seen["auth"] == f"Basic {expected}"
# R-49: Idempotency-Key on every POST.
assert seen["idem"], "Idempotency-Key must ride the create POST"
@pytest.mark.asyncio
async def test_create_session_subscription_mode_carries_recurring_interval():
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["body"] = form_body(request)
return httpx.Response(
200,
json={
"id": "cs_test_sub",
"url": "https://checkout.stripe.com/pay/cs_test_sub",
"status": "open",
"payment_status": "unpaid",
"mode": "subscription",
},
)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
provider = StripePaymentProvider(
http_client=client, api_key=KEY, webhook_secret=SECRET
)
await provider.create_checkout_session(spec(mode="subscription", interval="month"))
# R-35: one-time vs recurring differs ONLY by the interval field.
assert (
seen["body"]["line_items[0][price_data][recurring][interval]"] == "month"
)
@pytest.mark.asyncio
async def test_update_subscription_includes_item_id_dual_charge_guard():
"""R-47/MH-3d: `items[0][id]` MUST ride the update — omitting it
ADDS the new price alongside the old (dual-charge bug)."""
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET":
return httpx.Response(
200,
json={
"id": "sub_test_1",
"status": "active",
"items": {"data": [{"id": "si_test_item_1"}]},
},
)
seen["body"] = form_body(request)
seen["path"] = request.url.path
return httpx.Response(200, json={"id": "sub_test_1", "status": "active"})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
provider = StripePaymentProvider(
http_client=client, api_key=KEY, webhook_secret=SECRET
)
ref = await provider.update_subscription("sub_test_1", 29900)
assert ref is not None and ref.id == "sub_test_1"
assert seen["path"] == "/v1/subscriptions/sub_test_1"
assert seen["body"]["items[0][id]"] == "si_test_item_1", (
"the subscription_item id MUST be in the update body (R-47)"
)
assert seen["body"]["items[0][price_data][unit_amount]"] == "29900"
@pytest.mark.asyncio
async def test_cancel_subscription_immediate_is_delete():
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["method"] = request.method
return httpx.Response(
200, json={"id": "sub_test_1", "status": "canceled"}
)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
provider = StripePaymentProvider(
http_client=client, api_key=KEY, webhook_secret=SECRET
)
ref = await provider.cancel_subscription("sub_test_1")
assert ref is not None
assert seen["method"] == "DELETE" # R-46: immediate = DELETE
@pytest.mark.asyncio
async def test_error_redacts_api_key():
"""A-305: the api key must NEVER appear in a raised exception."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(402, text=f"card declined weirdness {KEY}")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
provider = StripePaymentProvider(
http_client=client, api_key=KEY, webhook_secret=SECRET
)
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await provider.create_checkout_session(spec())
assert KEY not in str(excinfo.value), "api key leaked in the error"
@pytest.mark.asyncio
async def test_retrieve_session_404_maps_to_none():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"error": {"message": "no such session"}})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
provider = StripePaymentProvider(
http_client=client, api_key=KEY, webhook_secret=SECRET
)
assert await provider.retrieve_session("cs_unknown") is None
class TestVerifyWebhook:
def _delivery(self, event_type: str) -> tuple[bytes, str, str]:
event_id = f"evt_{event_type.replace('.', '_')}"
payload: dict[str, Any] = {
"id": event_id,
"type": event_type,
"data": {"object": {"id": "cs_test_abc", "status": "complete"}},
}
raw = json.dumps(payload).encode()
header = mint_signature(SECRET, raw)
return raw, header, event_id
def test_checkout_completed_normalizes(self) -> None:
provider, _ = make_provider(lambda request: httpx.Response(200))
raw, header, event_id = self._delivery("checkout.session.completed")
verified = provider.verify_webhook(raw, header)
assert verified.event.name == "checkout.completed"
assert verified.provider_event_id == event_id
def test_vendor_payload_keys_map_to_canonical_keys(self) -> None:
"""Final-review regression (wire desync): Stripe's vendor object
spells the session id `id` and the subscription `subscription` —
normalize_event must translate them onto the canonical keys
(`session_id`, `subscription_id`) the processing route reads, or
every real-Stripe delivery resolves no local row and lands
unmatched with NO entitlement."""
provider, _ = make_provider(lambda request: httpx.Response(200))
def delivery(event: dict[str, Any]) -> tuple[bytes, str]:
raw = json.dumps(event).encode()
return raw, mint_signature(SECRET, raw)
raw, header = delivery(
{
"id": "evt_c1",
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_real_123",
"payment_status": "paid",
"subscription": "sub_real_9",
}
},
}
)
verified = provider.verify_webhook(raw, header)
assert verified.event.name == "checkout.completed"
assert verified.event.payload["session_id"] == "cs_real_123"
raw, header = delivery(
{
"id": "evt_i1",
"type": "invoice.payment_succeeded",
"data": {"object": {"subscription": "sub_real_9"}},
}
)
verified = provider.verify_webhook(raw, header)
assert verified.event.name == "invoice.paid"
assert verified.event.payload["subscription_id"] == "sub_real_9"
def test_session_shape_coerces_unknown_vendor_values(self) -> None:
"""G-306 (never 500): a vendor body with unexpected status
spellings must coerce to the honest default, never crash the
route with a pydantic ValidationError."""
body: dict[str, Any] = {
"id": "cs_x",
"status": "processing", # not in our enum
"payment_status": "pending", # not in our enum
"mode": "hosted", # not in our enum
}
session = StripePaymentProvider._session_from_api(body)
assert session.status == "open"
assert session.payment_status == "unpaid"
assert session.mode == "payment"
def test_dual_invoice_events_map_to_invoice_paid(self) -> None:
"""R-43: both vendor invoice events normalize to OUR
invoice.paid (accepted as one)."""
provider, _ = make_provider(lambda request: httpx.Response(200))
for vendor in ("invoice.paid", "invoice.payment_succeeded"):
raw, header, _ = self._delivery(vendor)
assert provider.verify_webhook(raw, header).event.name == "invoice.paid"
def test_subscription_deleted_maps_to_cancelled(self) -> None:
provider, _ = make_provider(lambda request: httpx.Response(200))
raw, header, _ = self._delivery("customer.subscription.deleted")
assert (
provider.verify_webhook(raw, header).event.name
== "subscription.cancelled"
)
def test_unmapped_event_type_raises(self) -> None:
provider, _ = make_provider(lambda request: httpx.Response(200))
raw, header, _ = self._delivery("charge.refunded")
with pytest.raises(UnknownPaymentProviderError, match="unmapped"):
provider.verify_webhook(raw, header)
def test_bad_signature_raises(self) -> None:
provider, _ = make_provider(lambda request: httpx.Response(200))
raw, _, _ = self._delivery("invoice.paid")
import time as _time
fresh_header = f"t={int(_time.time())},v1=deadbeef"
with pytest.raises(Exception, match="no v1 candidate matches"):
provider.verify_webhook(raw, fresh_header)
@@ -0,0 +1,579 @@
"""Checkout + webhook route tests (REQ-9-003, G-301/G-302/G-303).
The E2E payment proof without cloud (D-075): admin checkout →
admin-gated mock complete → the REAL webhook path → entitlement exists.
Security matrix: unsigned → 401 (never processed), bad signature →
401, duplicate → 200 idempotent, unknown target → 200 + unmatched row
(G-302), distinct event ids same session → one purchase (G-303).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.billing.mock import MockPaymentProvider
from ai_service.billing.store import SQLiteBillingStore
from ai_service.config import Settings
from ai_service.main import create_app
from ..conftest import SUITE_LEARNERS
ADMIN_TOKEN = "test-billing-admin"
SECRET = "whsec_test_billing_webhook"
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
def make_client(
tmp_path: Path,
billing_store: SQLiteBillingStore,
*,
admin_token: str | None = ADMIN_TOKEN,
) -> TestClient:
"""Full app with state-injected stores + a FIXED-secret mock
provider (deterministic signatures; the factory's per-boot random
would make minting impossible)."""
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=SecretStr(admin_token or ""),
webhook_secret=SECRET,
)
app = create_app(settings)
app.state.billing_store = billing_store # state-injection (G-9)
app.state.payment_provider = MockPaymentProvider(webhook_secret=SECRET)
return TestClient(app)
@pytest.fixture()
def client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[TestClient]:
with make_client(tmp_path, billing_store) as c:
yield c
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
def minted_event(
client: TestClient, name: str, payload: dict[str, object]
) -> tuple[bytes, dict[str, str]]:
"""A properly-signed webhook delivery minted by the app's OWN mock
provider (the exact bytes + signature a provider would deliver —
mint/verify share the scheme by construction, D-082)."""
provider = client.app.state.payment_provider # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
assert isinstance(provider, MockPaymentProvider)
raw, header, _ = provider.mint_event(name, dict(payload))
return raw, {"Stripe-Signature": header}
class TestCheckoutRoute:
def test_fail_closed_without_token(self, client: TestClient) -> None:
response = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-bundle", "employer_id": "emp-1"},
)
assert response.status_code == 403
def test_fail_closed_with_wrong_token(self, client: TestClient) -> None:
response = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-bundle", "employer_id": "emp-1"},
headers={"X-Admin-Token": "wrong"},
)
assert response.status_code == 403
def test_unknown_item_404(self, client: TestClient) -> None:
response = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-nope", "employer_id": "emp-1"},
headers=admin_headers(),
)
assert response.status_code == 404
def test_checkout_creates_session_and_local_row(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
response = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-bundle", "employer_id": "emp-1"},
headers=admin_headers(),
)
assert response.status_code == 201
body = response.json()
assert body["mode"] == "payment"
assert body["provider_session_id"].startswith("cs_mock_")
assert body["url"]
local = billing_store.get_checkout_session(body["session_id"])
assert local is not None
assert local.provider_session_id == body["provider_session_id"]
assert local.employer_id == "emp-1"
# D-078 snapshot: the row carries the catalog price at buy time.
assert local.price_cents == 39900
assert local.item_name == "Bundle"
def test_checkout_subscription_mode(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
response = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "plan-pro", "employer_id": "emp-2"},
headers=admin_headers(),
)
assert response.status_code == 201
assert response.json()["mode"] == "subscription"
def test_two_checkouts_never_collide_on_the_placeholder(
self, client: TestClient
) -> None:
"""Final-review regression: the pre-bind provider_session_id
placeholder must be UNIQUE per row — two checkouts (two admins,
or one double-click racing the provider call) collided on the
UNIQUE constraint and the second POST 500'd."""
first = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-single", "employer_id": "emp-a"},
headers=admin_headers(),
)
second = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-single", "employer_id": "emp-b"},
headers=admin_headers(),
)
assert first.status_code == 201, first.text
assert second.status_code == 201, second.text
assert (
first.json()["provider_session_id"]
!= second.json()["provider_session_id"]
)
class TestWebhookSecurity:
"""MH-3a: unsigned → 401, never processed. G-302: unknown targets
→ 200 + unmatched, no entitlement. G-303: distinct event ids, same
session → one purchase."""
def _checkout(self, client: TestClient, employer: str = "emp-1") -> str:
response = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-bundle", "employer_id": employer},
headers=admin_headers(),
)
assert response.status_code == 201
return response.json()["provider_session_id"]
def test_unsigned_webhook_401(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
response = client.post(
"/v1/billing/webhook",
content=b'{"id": "evt_x", "type": "checkout.completed"}',
headers={"Content-Type": "application/json"},
)
assert response.status_code == 401
# Never processed: zero provider events recorded.
assert billing_store.list_billing_events(kind="provider") == []
def test_bad_signature_401(self, client: TestClient) -> None:
import time
response = client.post(
"/v1/billing/webhook",
content=b'{"id": "evt_x", "type": "checkout.completed"}',
headers={
"Content-Type": "application/json",
"Stripe-Signature": f"t={int(time.time())},v1=deadbeef",
},
)
assert response.status_code == 401
def test_valid_delivery_grants_entitlement(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
provider_session_id = self._checkout(client)
raw, headers = minted_event(
client,
"checkout.completed",
{"session_id": provider_session_id, "payment_status": "paid"},
)
response = client.post(
"/v1/billing/webhook", content=raw, headers=headers
)
assert response.status_code == 200
entitlement = billing_store.get_entitlement("emp-1")
assert entitlement["purchase"] is not None
assert entitlement["purchase"]["posts_limit"] == 10
assert entitlement["purchase"]["package_name"] == "Bundle"
def test_duplicate_delivery_idempotent(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-3b: same event delivered twice → 200, single entitlement."""
provider_session_id = self._checkout(client)
raw, headers = minted_event(
client,
"checkout.completed",
{"session_id": provider_session_id, "payment_status": "paid"},
)
first = client.post("/v1/billing/webhook", content=raw, headers=headers)
second = client.post("/v1/billing/webhook", content=raw, headers=headers)
assert first.status_code == 200
assert second.status_code == 200
# One purchase — the dedupe refused the replay.
events = billing_store.list_billing_events(kind="provider")
grants = [e for e in events if e.name == "checkout.completed"]
assert len(grants) == 1
def test_distinct_event_ids_same_session_one_purchase(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""G-303: two DIFFERENT provider event ids announcing the same
completion (Stripe's dual invoice events) → exactly one
purchase row."""
provider_session_id = self._checkout(client)
for _n in (1, 2):
raw, headers = minted_event(
client,
"checkout.completed",
{"session_id": provider_session_id, "payment_status": "paid"},
)
response = client.post(
"/v1/billing/webhook", content=raw, headers=headers
)
assert response.status_code == 200
entitlement = billing_store.get_entitlement("emp-1")
assert entitlement["purchase"] is not None
# posts_used still 0 — ONE grant, not two stacked purchases.
assert entitlement["purchase"]["posts_used"] == 0
def test_unknown_session_unmatched_no_entitlement(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-3f (G-302): a signed event for a session the admin never
created → 200, zero purchases, one unmatched audit row."""
raw, headers = minted_event(
client,
"checkout.completed",
{"session_id": "cs_mock_forged", "payment_status": "paid"},
)
response = client.post(
"/v1/billing/webhook", content=raw, headers=headers
)
assert response.status_code == 200
assert billing_store.get_entitlement("emp-1")["purchase"] is None
unmatched = billing_store.list_billing_events(kind="unmatched")
assert len(unmatched) == 1
assert unmatched[0].name == "checkout.completed"
def test_inflated_payload_amount_ignored(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""G-302: the payload is an ID reference — an inflated amount
field never rewrites the stored snapshot (D-078)."""
provider_session_id = self._checkout(client)
raw, headers = minted_event(
client,
"checkout.completed",
{
"session_id": provider_session_id,
"payment_status": "paid",
"amount_total": 1,
"price_cents": 1,
},
)
client.post("/v1/billing/webhook", content=raw, headers=headers)
entitlement = billing_store.get_entitlement("emp-1")
assert entitlement["purchase"]["posts_limit"] == 10 # snapshot
# price stays the $399 snapshot — no field on the entitlement
# exposes payload amounts; the purchase row is the proof:
purchase = billing_store.get_entitlement("emp-1")["purchase"]
assert purchase is not None
class TestMockCompleteE2E:
"""G-301 + MH-3c: admin checkout → ADMIN-GATED mock complete → the
REAL webhook path → entitlement exists. No-token → 403."""
def _checkout(self, client: TestClient, employer: str = "emp-e2e") -> str:
response = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-single", "employer_id": employer},
headers=admin_headers(),
)
assert response.status_code == 201
return response.json()["provider_session_id"]
def test_complete_without_token_403(self, client: TestClient) -> None:
provider_session_id = self._checkout(client)
response = client.post(
"/v1/billing/admin/mock/complete",
json={"provider_session_id": provider_session_id},
)
assert response.status_code == 403 # G-301: admin-gated
def test_e2e_checkout_complete_entitlement(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
provider_session_id = self._checkout(client)
response = client.post(
"/v1/billing/admin/mock/complete",
json={"provider_session_id": provider_session_id},
headers=admin_headers(),
)
assert response.status_code == 200
assert response.json()["completed"] is True
assert response.json()["payment_status"] == "paid"
# The REAL webhook path ran: the entitlement exists (MH-3c).
entitlement = billing_store.get_entitlement("emp-e2e")
assert entitlement["purchase"] is not None
assert entitlement["purchase"]["posts_limit"] == 1
# The provider event landed (signed delivery recorded).
provider_events = billing_store.list_billing_events(kind="provider")
assert any(e.name == "checkout.completed" for e in provider_events)
def test_e2e_subscription_creates_subscription(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
response = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "plan-pro", "employer_id": "emp-sub"},
headers=admin_headers(),
)
assert response.status_code == 201
provider_session_id = response.json()["provider_session_id"]
complete = client.post(
"/v1/billing/admin/mock/complete",
json={"provider_session_id": provider_session_id},
headers=admin_headers(),
)
assert complete.status_code == 200
entitlement = billing_store.get_entitlement("emp-sub")
assert entitlement["subscription"] is not None
assert entitlement["subscription"]["status"] == "active"
assert entitlement["subscription"]["plan_name"] == "Pro"
def test_complete_unknown_session_404(self, client: TestClient) -> None:
response = client.post(
"/v1/billing/admin/mock/complete",
json={"provider_session_id": "cs_mock_ghost"},
headers=admin_headers(),
)
assert response.status_code == 404
def test_double_complete_idempotent_never_500(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""G-301 idempotency (the route owns it — the provider's pinned
contract raises on re-completion): an operator double-click /
retry re-completes an ALREADY-complete session → 200, exactly
ONE purchase (G-303 per-session exactly-once), never a 500."""
provider_session_id = self._checkout(client)
first = client.post(
"/v1/billing/admin/mock/complete",
json={"provider_session_id": provider_session_id},
headers=admin_headers(),
)
assert first.status_code == 200
second = client.post(
"/v1/billing/admin/mock/complete",
json={"provider_session_id": provider_session_id},
headers=admin_headers(),
)
assert second.status_code == 200
assert second.json()["completed"] is True
# Exactly one purchase — the replayed completion event could
# not double-grant (G-303's UniqueConstraint).
purchases = billing_store.get_entitlement("emp-e2e")
assert purchases["purchase"]["posts_limit"] == 1
events = billing_store.list_billing_events(kind="unmatched")
assert any(
e.name == "checkout.completed" and "already granted" in str(e.detail)
for e in events
)
def test_crash_between_processing_and_dedupe_recovers(
self, client: TestClient, billing_store: SQLiteBillingStore, monkeypatch
) -> None:
"""Final-review regression (at-least-once + grant-loss): if the
process dies AFTER the grant but BEFORE the event id was
recorded, the provider's retry must still land the entitlement
(the dedupe key is written after the effects; the replay is a
no-op via G-303). Under the old order the retry returned
'duplicate' and the grant was lost forever."""
provider_session_id = self._checkout(client)
raw, headers = minted_event(
client,
"checkout.completed",
{"session_id": provider_session_id, "payment_status": "paid"},
)
# Crash exactly between effects and the dedupe write.
original_insert = billing_store.insert_provider_event
def crash_then_insert(event_id, event_name, payload):
raise RuntimeError("simulated crash before the dedupe write")
monkeypatch.setattr(
billing_store, "insert_provider_event", crash_then_insert
)
with pytest.raises(RuntimeError):
client.post("/v1/billing/webhook", content=raw, headers=headers)
# The grant already happened (effects first).
assert billing_store.get_entitlement("emp-e2e")["purchase"] is not None
monkeypatch.setattr(
billing_store, "insert_provider_event", original_insert
)
# The retry: dedupe records the id, the replayed grant is a
# G-303 no-op — the entitlement SURVIVES (one purchase).
response = client.post("/v1/billing/webhook", content=raw, headers=headers)
assert response.status_code == 200
entitlement = billing_store.get_entitlement("emp-e2e")
assert entitlement["purchase"] is not None
assert entitlement["purchase"]["posts_limit"] == 1
def test_unpaid_completion_never_grants(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""Final-review regression (R-37): a signed checkout.completed
delivery whose payment_status is unpaid/processing records the
delivery but grants NOTHING — status alone never grants; a
later paid delivery grants exactly once."""
provider_session_id = self._checkout(client)
raw, headers = minted_event(
client,
"checkout.completed",
{"session_id": provider_session_id, "payment_status": "unpaid"},
)
response = client.post(
"/v1/billing/webhook", content=raw, headers=headers
)
assert response.status_code == 200
assert billing_store.get_entitlement("emp-e2e")["purchase"] is None
# The paid delivery grants.
raw2, headers2 = minted_event(
client,
"checkout.completed",
{"session_id": provider_session_id, "payment_status": "paid"},
)
response2 = client.post(
"/v1/billing/webhook", content=raw2, headers=headers2
)
assert response2.status_code == 200
assert billing_store.get_entitlement("emp-e2e")["purchase"] is not None
def test_complete_lost_provider_state_404_not_500(
self, client: TestClient
) -> None:
"""A restart clears the mock's in-memory sessions while the
local row persists — completing the stale session must be an
honest 404, never a raw 500 (G-306)."""
provider_session_id = self._checkout(client)
# Simulate the restart: a fresh provider instance (empty
# in-memory session map) under the same secret.
client.app.state.payment_provider = MockPaymentProvider( # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
webhook_secret=SECRET
)
response = client.post(
"/v1/billing/admin/mock/complete",
json={"provider_session_id": provider_session_id},
headers=admin_headers(),
)
assert response.status_code == 404
assert "no longer knows" in response.json()["detail"]
def test_mock_complete_404s_under_stripe_provider(
self, client: TestClient
) -> None:
"""G-301 (second arm): on a stripe-configured box the
mock-complete route must be GONE — the isinstance gate is the
defense-in-depth behind main.py's mount story (the router
mounts whole; the route refuses under a non-mock provider)."""
import httpx
from ai_service.billing.stripe import StripePaymentProvider
provider_session_id = self._checkout(client)
client.app.state.payment_provider = StripePaymentProvider( # type: ignore[attr-defined] # TestClient.app is typed ASGIApp; runtime FastAPI
http_client=httpx.AsyncClient(
transport=httpx.MockTransport(
lambda request: httpx.Response(200, json={})
)
),
api_key="sk_test_never_sent",
webhook_secret=SECRET,
)
response = client.post(
"/v1/billing/admin/mock/complete",
json={"provider_session_id": provider_session_id},
headers=admin_headers(),
)
assert response.status_code == 404 # never reachable under stripe
def test_signed_unknown_event_type_200_unmatched(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""G-306 (never 500): a correctly-SIGNED delivery of an event
type outside our canonical vocabulary (Stripe's real streams
deliver many types — charge.refunded etc.) is honest unmatched
audit material: 200 + one unmatched row, never a crashed
receiver (Stripe disables endpoints on error storms)."""
raw, headers = minted_event(
client, "charge.refunded", {"session_id": "cs_mock_any"}
)
response = client.post(
"/v1/billing/webhook", content=raw, headers=headers
)
assert response.status_code == 200
unmatched = billing_store.list_billing_events(kind="unmatched")
assert len(unmatched) == 1
assert unmatched[0].name == "unmapped.event"
assert "unmapped provider event" in str(unmatched[0].detail)
class TestPaymentBootFallback:
"""G-11 (the payment half, voice/embedding precedent): a
misconfigured stripe selection must never crash the unattended boot
— the app comes up on the mock provider with the loud fix-pointer
log (test_search.py's TestBootFallback pattern)."""
def test_stripe_unconfigured_boots_with_mock_and_loud_log(
self, tmp_path: Path, billing_store: SQLiteBillingStore, caplog
) -> None:
import logging
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "boot.db",
sandbox_dir=tmp_path / "sandboxes",
webhook_secret=SECRET,
payment_provider="stripe",
payment_api_key=SecretStr("sk_test_unset_origin"),
# public_base_url intentionally unset → G-310 validation
# fails → the G-11 fallback must engage.
)
app = create_app(settings)
app.state.billing_store = billing_store
with caplog.at_level(logging.WARNING, logger="ai_service.main"):
with TestClient(app) as c:
assert c.get("/health").status_code == 200
assert isinstance(
app.state.payment_provider, MockPaymentProvider
)
assert any(
"payment provider" in r.message and "falling back to mock" in r.message
for r in caplog.records
), "the fallback must be LOUD (G-11)"
@@ -0,0 +1,162 @@
"""Webhook HMAC verification matrix (Task 1-1-04, D-077, R-38..R-40).
Pins (each maps to a security property of webhooks.py):
- good signature verifies, returns the parsed (t, v1) header
- tampered body fails (the signature binds the exact raw bytes)
- missing header fails; empty string header fails the same way
- stale timestamp (outside the ±300s window) fails; fresh passes
- multiple v1 candidates: any match wins (secret rolls, R-38)
- v0-only header rejected (non-v1 schemes ignored — downgrade protection)
- tampered timestamp fails (t is bound into the signed payload)
- unknown scheme elements never crash the parse (ignored, not fatal)
- replay of the SAME delivery verifies twice (idempotent at the verify
layer — dedupe is the store/route's job, R-45)
"""
from __future__ import annotations
import time
import pytest
from ai_service.billing.base import WebhookSignatureError
from ai_service.billing.webhooks import mint, verify
SECRET = "whsec_test_secret_123"
OTHER_SECRET = "whsec_rotated_secret_456"
BODY = b'{"id": "evt_1", "name": "invoice.paid", "payload": {}}'
NOW = 1_800_000_000.0
class TestGoodSignatures:
def test_good_signature_verifies(self) -> None:
header = mint(SECRET, BODY, now=NOW)
timestamp, v1s = verify(BODY, header, SECRET, now=NOW)
assert timestamp is not None
assert len(v1s) == 1
def test_header_shape_t_and_v1(self) -> None:
header = mint(SECRET, BODY, now=NOW)
# R-38's wire shape: t=<unix_seconds>,v1=<hex> on one line.
assert header.startswith("t=1800000000,v1=")
assert len(header.split("v1=", 1)[1]) == 64 # sha256 hex
def test_mint_uses_wall_clock_by_default(self) -> None:
before = time.time()
header = mint(SECRET, BODY)
minted_t = int(header.split(",", 1)[0].removeprefix("t="))
assert before - 1 <= minted_t <= time.time() + 1
def test_verify_uses_wall_clock_by_default(self) -> None:
header = mint(SECRET, BODY) # stamped with the real now
verify(BODY, header, SECRET) # must be inside the window
def test_same_delivery_replays_green(self) -> None:
# R-45: at-least-once delivery — the SAME signed bytes verify
# any number of times; dedupe is downstream's concern.
header = mint(SECRET, BODY, now=NOW)
verify(BODY, header, SECRET, now=NOW)
verify(BODY, header, SECRET, now=NOW)
class TestTampering:
def test_tampered_body_fails(self) -> None:
header = mint(SECRET, BODY, now=NOW)
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
verify(BODY + b"x", header, SECRET, now=NOW)
def test_wrong_secret_fails(self) -> None:
header = mint(OTHER_SECRET, BODY, now=NOW)
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
verify(BODY, header, SECRET, now=NOW)
def test_tampered_timestamp_fails(self) -> None:
header = mint(SECRET, BODY, now=NOW)
minted_v1 = header.split("v1=", 1)[1]
# t participates in the signed payload: moving it without the
# secret invalidates v1 (R-40's attacker-immutable t).
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
verify(BODY, f"t={int(NOW) + 1},v1={minted_v1}", SECRET, now=NOW)
class TestMissingHeader:
def test_missing_header_fails(self) -> None:
with pytest.raises(WebhookSignatureError, match="missing"):
verify(BODY, None, SECRET, now=NOW)
def test_empty_header_fails(self) -> None:
with pytest.raises(WebhookSignatureError, match="missing"):
verify(BODY, "", SECRET, now=NOW)
class TestReplayWindow:
def test_stale_timestamp_fails(self) -> None:
# 400s old — outside the 300s window (R-40, risk-4: injected now,
# no sleeps).
header = mint(SECRET, BODY, now=NOW - 400)
with pytest.raises(WebhookSignatureError, match="stale timestamp"):
verify(BODY, header, SECRET, now=NOW)
def test_future_timestamp_fails(self) -> None:
# Clock-skew guard cuts both ways: a future-stamped delivery is
# also outside the window.
header = mint(SECRET, BODY, now=NOW + 400)
with pytest.raises(WebhookSignatureError, match="stale timestamp"):
verify(BODY, header, SECRET, now=NOW)
def test_fresh_inside_window_passes(self) -> None:
header = mint(SECRET, BODY, now=NOW - 299)
verify(BODY, header, SECRET, now=NOW)
def test_malformed_timestamp_fails(self) -> None:
header = mint(SECRET, BODY, now=NOW)
minted_v1 = header.split("v1=", 1)[1]
with pytest.raises(WebhookSignatureError, match="timestamp"):
verify(BODY, f"t=not-a-number,v1={minted_v1}", SECRET, now=NOW)
class TestMultiV1SecretRolls:
def test_multiple_v1_any_match_wins(self) -> None:
# R-38/R-53 risk-5: a secret roll sends both the old and new
# signatures; verify iterates ALL v1 candidates.
old_sig = mint(SECRET, BODY, now=NOW).split("v1=", 1)[1]
new_sig = mint(OTHER_SECRET, BODY, now=NOW).split("v1=", 1)[1]
header = f"t={int(NOW)},v1={old_sig},v1={new_sig}"
timestamp, v1s = verify(BODY, header, OTHER_SECRET, now=NOW)
assert timestamp == int(NOW)
assert len(v1s) == 2
def test_old_secret_still_verifies_during_roll(self) -> None:
old_sig = mint(SECRET, BODY, now=NOW).split("v1=", 1)[1]
new_sig = mint(OTHER_SECRET, BODY, now=NOW).split("v1=", 1)[1]
header = f"t={int(NOW)},v1={old_sig},v1={new_sig}"
verify(BODY, header, SECRET, now=NOW)
def test_all_v1_wrong_fails(self) -> None:
a = mint("whsec_a", BODY, now=NOW).split("v1=", 1)[1]
b = mint("whsec_b", BODY, now=NOW).split("v1=", 1)[1]
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
verify(BODY, f"t={int(NOW)},v1={a},v1={b}", SECRET, now=NOW)
class TestNonV1Schemes:
def test_v0_only_rejected(self) -> None:
# R-38: a v0-only header (Stripe test events) has NO v1 to
# check — ignored, never trusted (downgrade protection).
with pytest.raises(WebhookSignatureError, match="no v1 signature"):
verify(BODY, f"t={int(NOW)},v0=deadbeef", SECRET, now=NOW)
def test_unknown_scheme_ignored_not_fatal(self) -> None:
# Unknown scheme elements are skipped; a valid v1 among them
# still verifies (forward compatibility without downgrade).
v1 = mint(SECRET, BODY, now=NOW).split("v1=", 1)[1]
header = f"t={int(NOW)},v9=abc,v1={v1},v0=deadbeef"
verify(BODY, header, SECRET, now=NOW)
def test_v1_empty_value_fails(self) -> None:
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
verify(BODY, f"t={int(NOW)},v1=", SECRET, now=NOW)
def test_garbage_header_no_v1_fails(self) -> None:
with pytest.raises(WebhookSignatureError, match="no v1 signature"):
verify(BODY, "not a signature header at all", SECRET, now=NOW)
+26
View File
@@ -1,5 +1,6 @@
"""Test suite — conftest: mock provider only, zero network (enforced)."""
import os
import shutil
import tempfile
from collections.abc import Iterator
@@ -54,6 +55,31 @@ def sandbox_dir(tmp_path: Path) -> Iterator[Path]:
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture(autouse=True, scope="session")
def _hermetic_default_db_path() -> Iterator[None]:
"""P1 CI finding (v0.8): bare ``Settings()`` defaults db_path to
``~/.nextcraft/data/nextcraft.db`` — a fresh CI job container has no
``~/.nextcraft`` and sqlite dies with "unable to open database file".
This session-wide env default redirects ANY Settings() construction
that does not pin db_path explicitly into a hermetic session tmp dir
(pydantic-settings precedence: explicit kwarg > env). It kills the
whole class — dozens of bare ``Settings(...)`` sites across the suite
(embedding/sitemap/marketplace/defense fixtures) — without pinning
each call site. The conftest ``settings`` fixture and tests that pass
``db_path=tmp_path / ...`` keep their explicit (finer-grained) values.
"""
session_db_root = Path(tempfile.mkdtemp(prefix="nc-test-dbroot-"))
old = os.environ.get("AI_DB_PATH")
os.environ["AI_DB_PATH"] = str(session_db_root / "default.db")
yield
if old is None:
os.environ.pop("AI_DB_PATH", None)
else:
os.environ["AI_DB_PATH"] = old
shutil.rmtree(session_db_root, ignore_errors=True)
@pytest.fixture(autouse=True, scope="session")
def _sweep_home_test_sandboxes() -> Iterator[None]:
"""P2 (Task 2-1-03, G-110): session-end sweep of the anchored test roots.
@@ -0,0 +1,6 @@
"""Employer module tests (REQ-10-001) — store + KDF + contracts.
The module is proven standalone (no app, no routes, zero network):
tmp_path DB store mechanics, REAL scrypt KDF, G-315 contract pins.
The auth-route suite (test_auth.py) joins with Wave 1-2.
"""
+105
View File
@@ -0,0 +1,105 @@
"""Employers test fixtures (Task 1-2-01/02, REQ-10-001).
R-64: the tests/billing/ layout — module-local make_client + tmp_path
stores. G-313 (Task 1-2-02): this conftest carries a suite-wide
autouse fixture clearing BOTH throttle limiter module states — the
entire suite runs inside one 60s window and would self-429 against
the global cap otherwise (the _reset_apply_rate_window pattern,
tests/marketplace/conftest.py:123-131). The sanctioned deviation from
billing's no-conftest layout (R-64).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.config import Settings
from ai_service.employers.store import SQLiteEmployerStore
from ai_service.main import create_app
from ..conftest import SUITE_LEARNERS
#: Throwaway credentials (secrets policy: no real value in any file).
TEST_EMAIL = "founder@nextcraft.example"
TEST_PASSWORD = "correct-horse-battery-staple"
@pytest.fixture(autouse=True)
def _reset_employer_limiters() -> Iterator[None]:
"""G-313: BOTH limiter module states cleared before/after EVERY
employers test — module-level process state must never leak
between tests (the whole suite runs inside one 60s window and
would self-429 against the global cap otherwise)."""
from ai_service.api import employers as employers_api
employers_api._LOGIN_FAILURES.clear()
employers_api._AUTH_ATTEMPTS.clear()
yield
employers_api._LOGIN_FAILURES.clear()
employers_api._AUTH_ATTEMPTS.clear()
def make_client(
tmp_path: Path,
store: SQLiteEmployerStore | None = None,
**settings_overrides: object,
) -> TestClient:
"""Full app with the employer store state-injected (the G-9
override the lifespan's getattr-guard adopts)."""
if store is None:
store = SQLiteEmployerStore(db_path=tmp_path / "employers.db")
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
**settings_overrides, # type: ignore[arg-type]
)
app = create_app(settings)
app.state.employer_store = store # state-injection (G-9)
return TestClient(app)
@pytest.fixture()
def employer_store(tmp_path: Path) -> Iterator[SQLiteEmployerStore]:
store = SQLiteEmployerStore(db_path=tmp_path / "employers.db")
yield store
store.close()
@pytest.fixture()
def client(employer_store: SQLiteEmployerStore, tmp_path: Path) -> Iterator[TestClient]:
with make_client(tmp_path, employer_store) as c:
yield c
def register(
client: TestClient,
email: str = TEST_EMAIL,
password: str = TEST_PASSWORD,
) -> dict:
resp = client.post(
"/v1/employers/auth/register", json={"email": email, "password": password}
)
assert resp.status_code == 201, resp.text
return resp.json()
def login(
client: TestClient,
email: str = TEST_EMAIL,
password: str = TEST_PASSWORD,
) -> dict:
resp = client.post(
"/v1/employers/auth/login", json={"email": email, "password": password}
)
assert resp.status_code == 200, resp.text
return resp.json()
def bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@@ -0,0 +1,399 @@
"""Employer auth route tests (Task 1-2-01, REQ-10-001).
REAL scrypt throughout (D-096/R-64: no fake-hash path — ≈2 hashes
≈100 ms/test, honest). Pins: register→login→me→logout flow green;
duplicate register 409; wrong password 401 (no user-enumeration
difference); logout revokes; expired token 401 (injected now, no
sleeps); no-token 401; concurrent sessions per account allowed.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from ai_service.employers.store import SQLiteEmployerStore
from .conftest import TEST_EMAIL, TEST_PASSWORD, bearer, login, register
class TestAuthFlow:
def test_register_login_me_logout_flow_green(self, client: TestClient) -> None:
"""The headline flow: register (201 + session) → login (fresh
session) → me (account, unverified) → logout revokes."""
reg = register(client)
assert reg["token"]
assert reg["expires_at"]
log = login(client)
assert log["token"] and log["token"] != reg["token"]
me = client.get("/v1/employers/auth/me", headers=bearer(log["token"]))
assert me.status_code == 200, me.text
body = me.json()
assert body["account"]["email"] == TEST_EMAIL
assert body["account"]["email_verified"] is False # D-097
assert body["claimed_employer_id"] is None # no claim filed
assert body["claim"] is None # no claim filed
out = client.post("/v1/employers/auth/logout", headers=bearer(log["token"]))
assert out.status_code == 200
assert out.json() == {"revoked": True}
after = client.get("/v1/employers/auth/me", headers=bearer(log["token"]))
assert after.status_code == 401
def test_register_returns_live_session(self, client: TestClient) -> None:
"""The 201's token IS a live session (registration logs in)."""
reg = register(client)
me = client.get("/v1/employers/auth/me", headers=bearer(reg["token"]))
assert me.status_code == 200
def test_duplicate_register_409(self, client: TestClient) -> None:
register(client, email="dupe@example.com")
resp = client.post(
"/v1/employers/auth/register",
json={"email": "dupe@example.com", "password": TEST_PASSWORD},
)
assert resp.status_code == 409
def test_wrong_password_401(self, client: TestClient) -> None:
register(client)
resp = client.post(
"/v1/employers/auth/login",
json={"email": TEST_EMAIL, "password": "wrong-password-123"},
)
assert resp.status_code == 401
def test_no_user_enumeration_difference(self, client: TestClient) -> None:
"""Unknown email and wrong password produce the SAME 401 body
(D-090: no account-existence signal beyond the 401)."""
register(client)
unknown = client.post(
"/v1/employers/auth/login",
json={"email": "ghost@example.com", "password": "wrong-password-123"},
)
wrong = client.post(
"/v1/employers/auth/login",
json={"email": TEST_EMAIL, "password": "wrong-password-123"},
)
assert unknown.status_code == 401
assert wrong.status_code == 401
assert unknown.json() == wrong.json()
def test_email_normalized_on_login(
self, client: TestClient, tmp_path
) -> None:
"""G-315: register "User@Example.COM" → login with the raw
casing still finds the normalized account."""
resp = client.post(
"/v1/employers/auth/register",
json={"email": " User@Example.COM ", "password": TEST_PASSWORD},
)
assert resp.status_code == 201
resp = client.post(
"/v1/employers/auth/login",
json={"email": "user@example.com", "password": TEST_PASSWORD},
)
assert resp.status_code == 200
class TestSessionGate:
def test_no_token_401(self, client: TestClient) -> None:
assert client.get("/v1/employers/auth/me").status_code == 401
resp = client.get(
"/v1/employers/auth/me", headers={"Authorization": "Bearer "}
)
assert resp.status_code == 401
def test_unknown_token_401(self, client: TestClient) -> None:
resp = client.get("/v1/employers/auth/me", headers=bearer("no-such-token"))
assert resp.status_code == 401
def test_non_bearer_scheme_401(self, client: TestClient) -> None:
resp = client.get(
"/v1/employers/auth/me", headers={"Authorization": "Basic abc"}
)
assert resp.status_code == 401
def test_expired_token_401_injected_now(
self, employer_store: SQLiteEmployerStore, tmp_path
) -> None:
"""G-306 via the store's injectable now: mint at T, read at
T+8d → the session reads as absent → 401 (no sleeps)."""
from .conftest import make_client
# Wall-clock-relative mint: the 200 arm below must hold on ANY
# run date (the fixed 2026-09-30 mint turned red at 2026-10-07+).
login_at = datetime.now(UTC) - timedelta(days=1)
employer_store.insert_account(
email=TEST_EMAIL, password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA"
)
account = employer_store.get_account_by_email(TEST_EMAIL)
assert account is not None
employer_store.mint_session(
account_id=account.id, token="expired-token", now=login_at
)
with make_client(tmp_path, employer_store) as c:
# The route asks the store WITHOUT now → wall-clock. Inject
# the future read by calling the store directly with the
# expired timestamp to prove the row is past-TTL, then the
# route's live lookup of a wall-clock-valid token.
eight_days = login_at + timedelta(days=8)
row = employer_store.get_session_by_token_hash(
"expired-token", now=eight_days
)
assert row is None # expired — reads as absent
resp = c.get("/v1/employers/auth/me", headers=bearer("expired-token"))
# Wall clock (1d after mint) is INSIDE the 7-day TTL → 200.
assert resp.status_code == 200
# The lazy-TTL route 401 pin: a session whose expires_at is in
# the WALL-CLOCK past reads as absent at the route.
past_login = datetime.now(UTC) - timedelta(days=8)
employer_store.mint_session(
account_id=account.id, token="long-expired", now=past_login
)
with make_client(tmp_path, employer_store) as c:
resp = c.get("/v1/employers/auth/me", headers=bearer("long-expired"))
assert resp.status_code == 401
def test_logout_unknown_or_revoked_401(self, client: TestClient) -> None:
assert (
client.post("/v1/employers/auth/logout", headers=bearer("nope")).status_code
== 401
)
reg = register(client)
assert (
client.post("/v1/employers/auth/logout", headers=bearer(reg["token"]))
).status_code == 200
# Revoked: a second logout attempt 401s (the row is gone).
assert (
client.post("/v1/employers/auth/logout", headers=bearer(reg["token"]))
.status_code
== 401
)
def test_logout_no_token_401(self, client: TestClient) -> None:
assert client.post("/v1/employers/auth/logout").status_code == 401
class TestMeClaimState:
"""P6-review fix pin: /me carries the account's CLAIM state — the
P3 surfaces' claim-pending branch (employer-overview-client.tsx's
claimPending) consumed me.claim, but the route hardcoded None: a
pending claimer saw the dishonest no-entity claim-CTA. The claim
state rides /me end-to-end here."""
def test_me_pending_claim_visible(self, client: TestClient) -> None:
"""A filed-but-undecided claim rides /me as pending — the
dashboard renders the honest awaiting-review state from it."""
from ai_service.marketplace.base import Employer
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id="emp-me-pin",
source="seed-corpus",
external_id="emp-me-pin",
name="Me Labs",
website="https://example.com/me",
)
)
token = register(client)["token"]
resp = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-me-pin"},
headers=bearer(token),
)
assert resp.status_code == 201
me = client.get("/v1/employers/auth/me", headers=bearer(token))
assert me.status_code == 200
body = me.json()
assert body["claimed_employer_id"] is None # still pending
claim = body["claim"]
assert claim is not None # the P6 fix — the state is visible
assert claim["status"] == "pending"
assert claim["employer_id"] == "emp-me-pin"
assert claim["evidence"]["match"] is False # advisory, stored
def test_me_approved_claim_rides_with_entity(
self, client: TestClient
) -> None:
"""Approved: claimed_employer_id AND the approved claim both
ride /me (one honest read for the claimed surface)."""
from ai_service.marketplace.base import Employer
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id="emp-me-approved",
source="seed-corpus",
external_id="emp-me-approved",
name="Approved Labs",
website="https://example.com/approved",
)
)
token = register(client)["token"]
resp = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-me-approved"},
headers=bearer(token),
)
assert resp.status_code == 201
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = employer_store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None
me = client.get("/v1/employers/auth/me", headers=bearer(token))
body = me.json()
assert body["claimed_employer_id"] == "emp-me-approved"
assert body["claim"]["status"] == "approved"
def test_me_rejected_claim_stays_visible_until_superseded(
self, client: TestClient
) -> None:
"""A rejected claim is the honest claim state (never hidden —
the account owns no entity but its latest claim was rejected);
a NEW claim on another entity supersedes it in /me."""
from ai_service.marketplace.base import Employer
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
for eid in ("emp-me-rej-a", "emp-me-rej-b"):
store.upsert_employer(
Employer(
id=eid,
source="seed-corpus",
external_id=eid,
name=f"Rej {eid}",
website=f"https://example.com/{eid}",
)
)
token = register(client)["token"]
first = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-me-rej-a"},
headers=bearer(token),
)
assert first.status_code == 201
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
employer_store.decide_claim(
first.json()["id"], decision="rejected", operator="op-1"
)
me = client.get("/v1/employers/auth/me", headers=bearer(token))
assert me.json()["claim"]["status"] == "rejected"
# A new claim supersedes: the newest claim rides /me.
second = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-me-rej-b"},
headers=bearer(token),
)
assert second.status_code == 201
me = client.get("/v1/employers/auth/me", headers=bearer(token))
assert me.json()["claim"]["status"] == "pending"
assert me.json()["claim"]["employer_id"] == "emp-me-rej-b"
class TestConcurrentSessions:
def test_two_logins_both_me_green_one_logout_leaves_other(
self, client: TestClient
) -> None:
"""Concurrent sessions per account are allowed; logout is
per-token (revoking one leaves the other alive)."""
register(client)
first = login(client)["token"]
second = login(client)["token"]
assert first != second
assert (
client.get("/v1/employers/auth/me", headers=bearer(first)).status_code
== 200
)
assert (
client.get("/v1/employers/auth/me", headers=bearer(second)).status_code
== 200
)
assert client.post("/v1/employers/auth/logout", headers=bearer(first)).status_code == 200
assert (
client.get("/v1/employers/auth/me", headers=bearer(first)).status_code
== 401
)
assert (
client.get("/v1/employers/auth/me", headers=bearer(second)).status_code
== 200
)
class TestAuditTrail:
def test_register_and_logout_append_audit_events(
self, client: TestClient, employer_store: SQLiteEmployerStore
) -> None:
from sqlalchemy import text
reg = register(client)
client.post("/v1/employers/auth/logout", headers=bearer(reg["token"]))
with employer_store._engine.connect() as conn:
rows = conn.execute(
text(
"SELECT name, account_id FROM employer_audit_events "
"ORDER BY id"
)
).fetchall()
names = {row[0] for row in rows}
assert "register" in names
assert "logout" in names
assert all(row[1] is not None for row in rows)
class TestPiiScrub:
def test_invalid_register_422_never_echoes_password(
self, client: TestClient
) -> None:
"""R-56/open-risk-3 pin: a pydantic 422 on register must never
echo the submitted password into the response (err["input"])
— the pii_fields scrubber redacts password + email inputs."""
poison = "super-secret-password-123"
resp = client.post(
"/v1/employers/auth/register",
json={"email": "not-an-email", "password": poison},
)
assert resp.status_code == 422
assert poison not in resp.text, "422 must not echo the raw password"
# The scrubber redacts the offending input (never the msg).
detail = resp.json()["detail"]
assert all(err.get("input") != poison for err in detail)
def test_invalid_login_422_never_echoes_password(
self, client: TestClient
) -> None:
"""Same pin on the login route — both auth bodies scrub."""
poison = "another-secret-password-456"
resp = client.post(
"/v1/employers/auth/login",
json={"email": "x@example.com", "password": poison[:4]}, # too short
)
assert resp.status_code == 422
assert poison[:4] not in resp.text
def test_non_dict_body_422_never_echoes_password(
self, client: TestClient
) -> None:
"""Security-audit pin (task 1-3-01): a NON-DICT body (list/str)
errors at loc == ("body",) with input = the ENTIRE payload verbatim
— the field arm of the scrubber never fires. The body-level arm
redacts the echo (the raw password never rides the 422)."""
poison = "whole-body-secret-789"
resp = client.post(
"/v1/employers/auth/register",
json=[{"email": "a@example.com", "password": poison}],
)
assert resp.status_code == 422
assert poison not in resp.text, "list-body 422 must not echo the payload"
assert all(
err.get("input") == "[redacted]" or "password" not in str(err.get("input"))
for err in resp.json()["detail"]
)
# String body: same shape (a JSON-encoded string payload).
resp = client.post(
"/v1/employers/auth/register",
content=f'"password={poison}"',
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 422
assert poison not in resp.text
@@ -0,0 +1,202 @@
"""Employer contract tests (Task 1-1-01, REQ-10-001, G-315/D-097).
Pins:
- G-315: stdlib-only email validation — non-empty, exactly one "@",
non-empty domain, stripped + lowercased on normalize; pydantic
EmailStr is BANNED (no email-validator dependency)
- D-097: `email_verified` is Literal[False] — the field can never carry
True; an account is displayed as unverified
- D-086: password min length 8 (the honest validation floor)
- claim status vocabulary is CLOSED (D-089): pending|approved|rejected
- wire shapes: TokenResponse + EmployerMe (claimed employer_id + claim
state None until P2)
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from pydantic import ValidationError
from ai_service.employers.base import (
CLAIM_STATUSES,
EmployerAccount,
EmployerClaim,
EmployerMe,
LoginEmployerRequest,
RegisterEmployerRequest,
TokenResponse,
validate_email,
)
def _account_kwargs(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "acct-0001",
"email": "user@example.com",
"employer_id": None,
"created_at": datetime(2026, 9, 30, tzinfo=UTC),
"email_verified": False,
}
base.update(overrides)
return base
class TestEmployerAccount:
def test_account_roundtrip(self) -> None:
account = EmployerAccount(**_account_kwargs())
assert account.id == "acct-0001"
assert account.email == "user@example.com"
assert account.employer_id is None
assert account.email_verified is False
def test_email_verified_is_literal_false(self) -> None:
"""D-097: True is structurally unrepresentable."""
with pytest.raises(ValidationError):
EmployerAccount(**_account_kwargs(email_verified=True))
def test_employer_id_nullable_until_claim_approval(self) -> None:
account = EmployerAccount(
**_account_kwargs(employer_id="emp-source-external")
)
assert account.employer_id == "emp-source-external"
# ...and None remains valid (nullable-until-claim-approval).
assert EmployerAccount(**_account_kwargs()).employer_id is None
class TestEmployerClaim:
def _claim_kwargs(self, **overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "clm-0001",
"account_id": "acct-0001",
"employer_id": "emp-1",
"status": "pending",
"evidence": {"account_email_domain": "example.com", "match": True},
"created_at": datetime(2026, 9, 30, tzinfo=UTC),
}
base.update(overrides)
return base
def test_pending_claim_roundtrip(self) -> None:
claim = EmployerClaim(**self._claim_kwargs())
assert claim.status == "pending"
assert claim.decided_by is None
assert claim.decided_at is None
def test_decided_claim_roundtrip(self) -> None:
claim = EmployerClaim(
**self._claim_kwargs(
status="approved",
decided_by="operator-1",
decided_at=datetime(2026, 10, 1, tzinfo=UTC),
)
)
assert claim.status == "approved"
assert claim.decided_by == "operator-1"
assert claim.decided_at is not None
def test_status_vocabulary_is_closed(self) -> None:
"""D-089: unknown claim status rejected; all three accepted."""
for status in CLAIM_STATUSES:
EmployerClaim(**self._claim_kwargs(status=status))
with pytest.raises(ValidationError):
EmployerClaim(**self._claim_kwargs(status="bogus"))
class TestEmailValidation:
"""G-315: stdlib-only email discipline."""
@pytest.mark.parametrize(
("raw", "expected"),
[
("user@example.com", "user@example.com"),
(" User@Example.COM ", "user@example.com"),
("MiXeD@ExAmPlE.NeT", "mixed@example.net"),
],
)
def test_normalizes_strip_and_lowercase(self, raw: str, expected: str) -> None:
assert validate_email(raw) == expected
request = RegisterEmployerRequest(email=raw, password="hunter2222")
assert request.email == expected
@pytest.mark.parametrize(
"raw",
[
"",
" ",
"no-at-sign",
"double@@example.com",
"user@@example.com",
"@example.com",
"user@",
"a@b@c",
],
)
def test_rejects_dishonest_emails(self, raw: str) -> None:
with pytest.raises(ValidationError):
RegisterEmployerRequest(email=raw, password="hunter2222")
def test_single_at_with_domain_accepted(self) -> None:
"""G-315 is an honest structural floor, not an RFC masquerade:
exactly one "@" + non-empty parts passes without a DNS regex."""
assert validate_email("user@example") == "user@example"
def test_domain_must_be_non_empty(self) -> None:
with pytest.raises(ValueError, match="domain"):
validate_email("user@")
def test_emailstr_banned_no_email_validator_dep(self) -> None:
"""G-315: the field annotation is plain str — email-validator
(the EmailStr backer) is not in the venv and never will be."""
field = RegisterEmployerRequest.model_fields["email"]
assert field.annotation is str
class TestPasswordFloor:
"""D-086 honest validation: password min length 8."""
def test_short_password_rejected(self) -> None:
with pytest.raises(ValidationError):
RegisterEmployerRequest(email="user@example.com", password="hunter2")
def test_short_password_rejected_on_login_too(self) -> None:
with pytest.raises(ValidationError):
LoginEmployerRequest(email="user@example.com", password="hunter2")
def test_min_length_password_accepted(self) -> None:
request = LoginEmployerRequest(email="user@example.com", password="hunter22")
assert request.password == "hunter22"
class TestWireShapes:
def test_token_response_roundtrip(self) -> None:
response = TokenResponse(
token="raw-bearer-token", expires_at=datetime(2026, 10, 7, tzinfo=UTC)
)
assert response.token == "raw-bearer-token"
assert response.expires_at == datetime(2026, 10, 7, tzinfo=UTC)
def test_employer_me_defaults_none(self) -> None:
me = EmployerMe(account=EmployerAccount(**_account_kwargs()))
assert me.account.email_verified is False
assert me.claimed_employer_id is None
assert me.claim is None
def test_employer_me_with_claim_state(self) -> None:
claim = EmployerClaim(
id="clm-0001",
account_id="acct-0001",
employer_id="emp-1",
status="pending",
evidence={},
created_at=datetime(2026, 9, 30, tzinfo=UTC),
)
me = EmployerMe(
account=EmployerAccount(**_account_kwargs()),
claimed_employer_id=None,
claim=claim,
)
assert me.claim is claim
assert me.claimed_employer_id is None # pending — not approved yet
@@ -0,0 +1,339 @@
"""Self-serve checkout + entitlement view (Task 5-1-01, REQ-10-005, R-59).
Pins the employer-principal checkout transpose: session gate → claim
gate (403 + claim-CTA) → catalog 404 → the R-59 verbatim order (unique
pending placeholder → spec → provider call → bind) with the ONLY
changes the plan sanctions — the buyer derives from the session's
CLAIMED entity and metadata carries the account provenance. The
entitlement view must be the v0.9 shape, own-entity only, with honest
none/active/expired states.
Fixtures: the conftest make_client/register/bearer + the
test_postings_write.py claim_and_approve/seed precedents.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.billing.store import SQLiteBillingStore
from ai_service.marketplace.base import Employer
from .conftest import bearer, make_client, register
OWN_ENTITY = "emp-checkout-self"
OTHER_ENTITY = "emp-checkout-rival"
def seed_employer_rows(client: TestClient) -> None:
"""Both entities exist in the marketplace corpus (claim targets the
v0.6 ingested EmployerRow data)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="Checkout Labs",
website="https://example.com/checkout",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def claim_and_approve(client: TestClient, token: str, employer_id: str) -> None:
"""File the claim + drive the operator approve (the store path the
2-2 admin route composes) — binds account.employer_id."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
def checkout(client: TestClient, token: str, item_id: str):
return client.post(
"/v1/employers/billing/checkout",
headers=bearer(token),
json={"item_id": item_id},
)
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
@pytest.fixture()
def claimed(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[tuple[TestClient, str]]:
"""One registered + claimed-and-approved employer session with the
billing store state-injected (G-9). The store replaces app.state
AFTER the lifespan seeded the default store, so the catalog seed
runs explicitly against the injected store (D-078 idempotent —
exactly what the lifespan boot does)."""
from ai_service.billing.catalog import seed_billing_catalog
seed_billing_catalog(billing_store)
with make_client(tmp_path) as client:
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
yield client, token
class TestCheckoutRoute:
def test_no_token_401(self, tmp_path: Path) -> None:
"""G-311 order: unauthenticated → 401, never a catalog read."""
with make_client(tmp_path) as client:
resp = client.post(
"/v1/employers/billing/checkout", json={"item_id": "pkg-single"}
)
assert resp.status_code == 401
def test_unclaimed_403_claim_cta(self, tmp_path: Path) -> None:
"""Unclaimed session → 403 + the claim-CTA (D-090 class — the
buyer cannot derive from an account with no entity)."""
with make_client(tmp_path) as client:
token = register(client)["token"]
resp = checkout(client, token, "pkg-single")
assert resp.status_code == 403
detail = resp.json()["detail"]
assert detail["reason"] == "no_claimed_employer"
assert "claim_cta" in detail
def test_unknown_item_404(
self, claimed: tuple[TestClient, str]
) -> None:
"""The catalog lookup precedes every side effect (404 unknown
item — nothing persisted)."""
client, token = claimed
resp = checkout(client, token, "pkg-nonexistent")
assert resp.status_code == 404
assert resp.json()["detail"] == "unknown catalog item"
# Nothing persisted: no checkout row for the claimed entity.
assert client.app.state.billing_store.get_checkout_session_by_provider_id( # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
"pkg-nonexistent"
) is None
def test_checkout_201_shape_and_row(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""The R-59 transpose: 201 with the v0.9 CheckoutResponse shape
+ the persisted reconciliation row (snapshot fields + open/
unpaid status + the buyer = the CLAIMED entity)."""
client, token = claimed
resp = checkout(client, token, "pkg-single")
assert resp.status_code == 201, resp.text
body = resp.json()
assert set(body.keys()) == {
"session_id",
"provider_session_id",
"url",
"mode",
}
assert body["mode"] == "payment"
assert body["url"]
row = billing_store.get_checkout_session(body["session_id"])
assert row is not None
assert row.employer_id == OWN_ENTITY # buyer = claimed entity
assert row.item_id == "pkg-single"
assert row.item_name # D-078 snapshot
assert row.price_cents == 4900 # the seeded snapshot price
assert row.provider_session_id == body["provider_session_id"]
assert row.status == "open"
assert row.payment_status == "unpaid"
def test_subscription_mode_for_plans(
self, claimed: tuple[TestClient, str]
) -> None:
"""mode = subscription iff kind==plan (the R-59 derivation)."""
client, token = claimed
resp = checkout(client, token, "plan-pro")
assert resp.status_code == 201
assert resp.json()["mode"] == "subscription"
def test_metadata_provenance(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""R-59's ONLY sanctioned change: metadata employer_id = the
CLAIMED entity + operator_ref = the ACCOUNT provenance (D-076's
"operator" becomes account provenance). Pinned through the
spec the provider received (the mock provider has no spec
log — assert the account id shape lands in the spec via the
route's account row, and the row's employer binding)."""
client, token = claimed
resp = checkout(client, token, "pkg-bundle")
assert resp.status_code == 201
# The account's employer binding is what the route recorded.
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
me = client.get("/v1/employers/auth/me", headers=bearer(token))
account_id = me.json()["account"]["id"]
assert account_id.startswith("acct-")
row = billing_store.get_checkout_session(resp.json()["session_id"])
assert row is not None
assert row.employer_id == OWN_ENTITY
# The metadata provenance is pinned end-to-end in the E2E
# (mock-complete resolves the session by the provider id this
# row bound); here the account must exist and own the entity.
account = store.get_account(account_id)
assert account is not None
assert account.employer_id == OWN_ENTITY
def test_two_checkouts_never_collide(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""The unique-pending placeholder: two rapid checkouts both
persist (the final-review fix — a bare 'pending' collided on
the provider_session_id UNIQUE constraint)."""
client, token = claimed
first = checkout(client, token, "pkg-single")
second = checkout(client, token, "pkg-single")
assert first.status_code == 201
assert second.status_code == 201
assert (
first.json()["session_id"] != second.json()["session_id"]
)
class TestEntitlementView:
def test_unclaimed_403_claim_cta(self, tmp_path: Path) -> None:
with make_client(tmp_path) as client:
token = register(client)["token"]
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 403
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
def test_no_token_401(self, tmp_path: Path) -> None:
with make_client(tmp_path) as client:
resp = client.get("/v1/employers/billing/entitlement")
assert resp.status_code == 401
def test_honest_none_state(
self, claimed: tuple[TestClient, str]
) -> None:
"""No grant → purchase/subscription None (the honest empty
state, never fabricated)."""
client, token = claimed
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 200
body = resp.json()
assert body["employer_id"] == OWN_ENTITY
assert body["purchase"] is None
assert body["subscription"] is None
def test_active_state(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""A real grant renders: posts used/limit/expiry from the
store's truth (the v0.9 REQ-9-004 shape)."""
client, token = claimed
from ..billing.test_entitlement_parity import grant_posts
grant_posts(billing_store, OWN_ENTITY, 10)
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 200
purchase = resp.json()["purchase"]
assert purchase is not None
assert purchase["posts_limit"] == 10
assert purchase["posts_used"] == 0
assert purchase["expires_at"] is not None
def test_expired_state_is_honest(
self,
tmp_path: Path,
billing_store: SQLiteBillingStore,
) -> None:
"""An expired purchase never renders as active: get_entitlement
skips it (the lazy G-306 read) — the honest None, not a stale
package."""
with make_client(tmp_path) as client:
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
grant_row = billing_store.insert_checkout_session(
provider_session_id="cs_expired_pin",
employer_id=OWN_ENTITY,
item_id="pkg-single",
item_name="Single Post",
price_cents=4900,
mode="payment",
)
purchase = billing_store.create_purchase_from_session(
grant_row, posts_limit=5, duration_days=30
)
# Force the expiry into the past.
from sqlalchemy import text
from sqlmodel import Session
with Session(billing_store._engine) as session:
session.execute(
text(
"UPDATE billing_purchases SET expires_at = :past "
"WHERE id = :pid"
),
{"past": "2000-01-01 00:00:00", "pid": purchase.id},
)
session.commit()
assert purchase.id # the grant existed
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 200
assert resp.json()["purchase"] is None
def test_rival_entity_never_visible(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""Own-entity only: the rival's grant never renders in the
session employer's view."""
client, token = claimed
from ..billing.test_entitlement_parity import grant_posts
grant_posts(billing_store, OTHER_ENTITY, 10)
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 200
body = resp.json()
assert body["employer_id"] == OWN_ENTITY
assert body["purchase"] is None
@@ -0,0 +1,327 @@
"""Own-session mock-complete E2E (Task 5-1-02, REQ-10-005, MH-5a/5b).
The B2B loop closes machine-checked: register → login → claim →
admin approve → checkout → OWN mock-complete → the REAL webhook path →
purchase exists with posts available. The G-301 hole class is closed:
cross-session completion → 403, the route 404s under a stripe
provider, and a duplicate complete falls through already-complete —
G-303's per-session exactly-once refuses the double grant.
The admin-approve step drives the REAL operator route (the 2-2 admin
surface, X-Admin-Token) — the E2E proof runs the honest loop, not a
store shortcut.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.billing.store import SQLiteBillingStore
from ai_service.marketplace.base import Employer
from .conftest import TEST_EMAIL, TEST_PASSWORD, bearer, make_client, register
OWN_ENTITY = "emp-e2e-self"
OTHER_ENTITY = "emp-e2e-rival"
ADMIN_TOKEN = "test-checkout-e2e-admin"
SECRET = "whsec_test_employer_e2e"
def seed_employer_rows(client: TestClient) -> None:
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="E2E Labs",
website="https://example.com/e2e",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def admin_headers() -> dict[str, str]:
return {"X-Admin-Token": ADMIN_TOKEN}
def claim_approve_admin_route(
client: TestClient, token: str, employer_id: str
) -> None:
"""File the claim then drive the REAL operator approve route (the
2-2 admin surface — the honest loop)."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
approved = client.post(
f"/v1/employers/admin/claims/{resp.json()['id']}/approve",
headers=admin_headers(),
)
assert approved.status_code == 200, approved.text
assert approved.json()["claim"]["status"] == "approved"
def checkout(client: TestClient, token: str, item_id: str) -> dict:
resp = client.post(
"/v1/employers/billing/checkout",
headers=bearer(token),
json={"item_id": item_id},
)
assert resp.status_code == 201, resp.text
return resp.json()
def mock_complete(client: TestClient, token: str, provider_session_id: str):
return client.post(
"/v1/employers/billing/mock/complete",
headers=bearer(token),
json={"provider_session_id": provider_session_id},
)
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
@pytest.fixture()
def claimed(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[tuple[TestClient, str]]:
"""The full honest principal: registered → claim filed → operator
approved via the REAL admin route. The webhook secret is pinned so
minted events verify against the provider the app selected."""
from ai_service.billing.catalog import seed_billing_catalog
seed_billing_catalog(billing_store)
with make_client(
tmp_path, admin_token=SecretStr(ADMIN_TOKEN), webhook_secret=SECRET
) as client:
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
seed_employer_rows(client)
token = register(client)["token"]
claim_approve_admin_route(client, token, OWN_ENTITY)
yield client, token
def _purchase_count(store: SQLiteBillingStore, employer_id: str) -> int:
from sqlalchemy import text
from sqlmodel import Session
with Session(store._engine) as session:
return int(
session.execute(
text(
"SELECT COUNT(*) FROM billing_purchases "
"WHERE employer_id = :eid"
),
{"eid": employer_id},
).scalar_one()
)
class TestCheckoutE2E:
def test_full_loop_purchase_granted(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""MH-5a: register → login (the register mint IS the session) →
claim → admin approve → checkout → own mock-complete → the
REAL webhook path → purchase exists with posts available —
and the employer can CREATE a posting from the granted package
(the loop's proof point)."""
client, token = claimed
# Login proves the credential path (register mints the session;
# login re-derives one from the password).
login_resp = client.post(
"/v1/employers/auth/login",
json={"email": TEST_EMAIL, "password": TEST_PASSWORD},
)
assert login_resp.status_code == 200
token = login_resp.json()["token"]
body = checkout(client, token, "pkg-single")
complete = mock_complete(client, token, body["provider_session_id"])
assert complete.status_code == 200, complete.text
assert complete.json() == {"completed": True, "payment_status": "paid"}
# The REAL webhook path ran: the entitlement view (own-entity)
# renders the granted package with posts available.
view = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert view.status_code == 200
purchase = view.json()["purchase"]
assert purchase is not None
assert purchase["posts_limit"] == 1
assert purchase["posts_used"] == 0
assert purchase["expires_at"] is not None
# The grant answers the loop: a posting create succeeds (the
# package is live in the shared entitlement gate).
created = client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": "E2E Role", "description": "d", "skills": ["go"]},
)
assert created.status_code == 201, created.text
assert _purchase_count(billing_store, OWN_ENTITY) == 1
# The signed delivery landed (provider event recorded).
events = billing_store.list_billing_events(kind="provider")
assert any(e.name == "checkout.completed" for e in events)
def test_cross_session_complete_403(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""MH-5b (the G-301 hole class): a rival's provider_session_id
must 403 — completing another buyer's session is refused
BEFORE any provider state change or grant; the rival's session
stays open and ungranted."""
client, token = claimed
# The rival buys a session directly through the admin operator
# route (the v0.9 fallback — provenance: operator).
rival_checkout = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-single", "employer_id": OTHER_ENTITY},
headers=admin_headers(),
)
assert rival_checkout.status_code == 201
rival_id = rival_checkout.json()["provider_session_id"]
resp = mock_complete(client, token, rival_id)
assert resp.status_code == 403
assert "another employer entity" in resp.json()["detail"]
# Nothing granted, nothing flipped: the rival has no purchase.
assert _purchase_count(billing_store, OTHER_ENTITY) == 0
rival_row = billing_store.get_checkout_session_by_provider_id(rival_id)
assert rival_row is not None
assert rival_row.status == "open"
def test_unknown_session_404(self, claimed: tuple[TestClient, str]) -> None:
client, token = claimed
resp = mock_complete(client, token, "cs_mock_ghost")
assert resp.status_code == 404
assert resp.json()["detail"] == "unknown checkout session"
def test_unclaimed_403_claim_cta(self, tmp_path: Path) -> None:
"""The claim gate precedes everything (G-311 order)."""
with make_client(tmp_path) as client:
token = register(client)["token"]
resp = client.post(
"/v1/employers/billing/mock/complete",
headers=bearer(token),
json={"provider_session_id": "cs_mock_any"},
)
assert resp.status_code == 403
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
def test_no_token_401(self, tmp_path: Path) -> None:
with make_client(tmp_path) as client:
resp = client.post(
"/v1/employers/billing/mock/complete",
json={"provider_session_id": "cs_mock_any"},
)
assert resp.status_code == 401
def test_mock_complete_404_under_stripe_provider(
self, claimed: tuple[TestClient, str]
) -> None:
"""MH-5b (second arm): on a stripe-configured box the route
must be GONE — the isinstance gate is the defense-in-depth
behind the mount story (the router mounts whole; the route
refuses under a non-mock provider)."""
import httpx
from ai_service.billing.stripe import StripePaymentProvider
client, token = claimed
body = checkout(client, token, "pkg-single")
client.app.state.payment_provider = StripePaymentProvider( # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
http_client=httpx.AsyncClient(
transport=httpx.MockTransport(
lambda request: httpx.Response(200, json={})
)
),
api_key="sk_test_never_sent",
webhook_secret=SECRET,
)
resp = mock_complete(client, token, body["provider_session_id"])
assert resp.status_code == 404 # never reachable under stripe
def test_duplicate_complete_falls_through_no_double_grant(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""G-303: a duplicate complete (double-click) re-mints + re-
runs the REAL webhook path with a FRESH event id — the grant
is refused by the per-session UNIQUE constraint; 200 honest
(already complete), exactly ONE purchase, never a 500."""
client, token = claimed
body = checkout(client, token, "pkg-single")
first = mock_complete(client, token, body["provider_session_id"])
assert first.status_code == 200
second = mock_complete(client, token, body["provider_session_id"])
assert second.status_code == 200
assert second.json()["completed"] is True
assert _purchase_count(billing_store, OWN_ENTITY) == 1
# The refused re-grant is RECORDED (audit answers it).
events = billing_store.list_billing_events(kind="unmatched")
assert any(
e.name == "checkout.completed" and "already granted" in str(e.detail)
for e in events
)
# The posts are still available exactly once: one create
# consumes the single post, a second 402s (no phantom grant).
first_create = client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": "Role A", "description": "d", "skills": []},
)
assert first_create.status_code == 201
second_create = client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": "Role B", "description": "d", "skills": []},
)
assert second_create.status_code == 402
def test_subscription_checkout_completes_to_subscription(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""The plan arm: a subscription-mode own checkout completes
into an active subscription through the REAL webhook path."""
client, token = claimed
body = checkout(client, token, "plan-pro")
complete = mock_complete(client, token, body["provider_session_id"])
assert complete.status_code == 200
view = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
subscription = view.json()["subscription"]
assert subscription is not None
assert subscription["status"] == "active"
assert subscription["plan_name"] == "Pro"
@@ -0,0 +1,212 @@
"""claim_evidence tests — R-65 normalization + the advisory match
signal (Task 2-1-01, REQ-10-002).
Zero-dep discipline pinned: the module imports stdlib urllib.parse
only (no PSL library, no tldextract — R-66). The example.com corpus
(R-65) pins match=false corpus-wide: every seeded employer website is
an example.com placeholder, so a real registrant's email domain never
matches.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from ai_service.employers.claim_evidence import (
compute_claim_evidence,
domains_match,
normalize_email_domain,
normalize_website_domain,
)
CORPUS_PATH = (
Path(__file__).resolve().parents[2]
/ "ai_service"
/ "marketplace"
/ "seed_corpus.json"
)
class TestNormalizeWebsiteDomain:
"""The normalization table — scheme/path stripped, case-folded."""
@pytest.mark.parametrize(
("website", "expected"),
[
# The PLAN's verbatim example: scheme + path stripped.
("https://example.com/openai", "example.com"),
# Case folding (host, scheme, and a mixed-case path).
("HTTPS://Example.COM/OpenAI", "example.com"),
("https://EXAMPLE.com", "example.com"),
# Bare host without a scheme — the // prepended for the
# netloc parse (urlsplit alone would read it as a path).
("example.com", "example.com"),
("Example.COM", "example.com"),
# Path + query + fragment stripped.
("https://example.com/careers?tab=1#top", "example.com"),
# Deep path stripped to the bare host.
("https://sub.example.com/jobs/openai/123", "sub.example.com"),
# Port dropped (evidence is registrable, not origin-scoped
# — port is irrelevant to a domain match).
("https://example.com:8443/careers", "example.com"),
# The seed corpus's placeholder shape (R-65).
("https://example.com/openai", "example.com"),
],
)
def test_table(self, website: str, expected: str) -> None:
assert normalize_website_domain(website) == expected
@pytest.mark.parametrize(
"website",
["", "not a url", "https://", "mailto:x@example.com"],
)
def test_honest_absence(self, website: str) -> None:
"""Unparseable/empty → "" — never an exception (the claim route
must never 500 over an advisory signal). mailto: carries no
hostname — honest absence."""
assert normalize_website_domain(website) == ""
def test_unparseable_never_raises(self) -> None:
"""An unterminated IPv6 literal raises ValueError inside
urlsplit — swallowed into the honest empty string (evidence is
advisory, never a 500 on the claim route)."""
assert normalize_website_domain("https://[::1") == ""
class TestNormalizeEmailDomain:
@pytest.mark.parametrize(
("email", "expected"),
[
("founder@openai.example", "openai.example"),
# Lowercased (G-315 already normalizes at the account
# boundary; this re-derives, never trusts).
("Founder@OpenAI.Example", "openai.example"),
# The local part rides NOWHERE in the evidence.
("weird+tag@acme.com", "acme.com"),
],
)
def test_table(self, email: str, expected: str) -> None:
assert normalize_email_domain(email) == expected
@pytest.mark.parametrize("email", ["", "no-at-sign"])
def test_honest_absence(self, email: str) -> None:
assert normalize_email_domain(email) == ""
class TestDomainsMatch:
def test_same_registrable_domain_true(self) -> None:
"""The PLAN's match case: identical domains."""
assert domains_match("acme.com", "acme.com") is True
@pytest.mark.parametrize(
("email_domain", "website_domain"),
[
# Subdomain of the registrable domain — one endswith "." +
# the other, either orientation.
("acme.com", "shop.acme.com"),
("shop.acme.com", "acme.com"),
("acme.co.uk", "www.acme.co.uk"),
],
)
def test_suffix_match_true(
self, email_domain: str, website_domain: str
) -> None:
assert domains_match(email_domain, website_domain) is True
@pytest.mark.parametrize(
("email_domain", "website_domain"),
[
# Unrelated domains.
("acme.com", "openai.example"),
("acme.com", "notacme.com"),
# Prefix-lookalike is NOT a suffix ("acme.com" vs
# "fake-acme.com" — no boundary dot).
("acme.com", "fake-acme.com"),
# Either side empty → False (nothing claimed is nothing
# matched).
("", "acme.com"),
("acme.com", ""),
("", ""),
],
)
def test_no_match_false(self, email_domain: str, website_domain: str) -> None:
assert domains_match(email_domain, website_domain) is False
def test_multi_label_suffix_overmatch_documented(self) -> None:
"""The documented over-match (module docstring): "co.uk" is a
multi-label public suffix, not a registrable domain, yet the
suffix rule counts it. Accepted for an advisory signal — no
PSL, zero deps (R-66). This test PINS the behavior so a future
tightening can't drift silently."""
assert domains_match("mail.co.uk", "co.uk") is True
class TestComputeClaimEvidence:
def test_exact_shape(self) -> None:
"""The D-089 evidence dict — exact keys, computed at claim
time, stored as provenance (Task 2-1-03 pins the route)."""
evidence = compute_claim_evidence(
"founder@openai.example", "https://openai.example/careers"
)
assert evidence == {
"account_email_domain": "openai.example",
"entity_website_domain": "openai.example",
"match": True,
}
assert set(evidence) == {
"account_email_domain",
"entity_website_domain",
"match",
}
# The local part rides nowhere (PII boundary).
assert "founder" not in json.dumps(evidence)
def test_match_false_on_unrelated(self) -> None:
evidence = compute_claim_evidence(
"founder@acme.com", "https://openai.example/careers"
)
assert evidence == {
"account_email_domain": "acme.com",
"entity_website_domain": "openai.example",
"match": False,
}
def test_match_false_on_the_example_com_corpus(self) -> None:
"""R-65: every seeded employer website is an example.com
placeholder — a real registrant's email domain does NOT match
any seeded entity. match=false corpus-wide (D-089's
advisory-display premise)."""
corpus = json.loads(CORPUS_PATH.read_text(encoding="utf-8"))
employers = corpus["employers"]
assert len(employers) > 0
for employer in employers:
website = employer["website"]
assert "example.com" in website # the R-65 premise itself
evidence = compute_claim_evidence(
"founder@openai.example", website
)
assert evidence["entity_website_domain"] == "example.com"
assert evidence["match"] is False
class TestZeroDepDiscipline:
def test_module_is_stdlib_only(self) -> None:
"""R-66: the evidence module imports nothing beyond stdlib
(urllib.parse) — no PSL, no tldextract, never a new dep."""
import ai_service.employers.claim_evidence as module
imported = {
name
for name in vars(module)
if not name.startswith("_")
and getattr(getattr(module, name, None), "__module__", "")
== module.__name__
}
assert imported # the module's own names are present
# The ONLY module-level import is urllib.parse (stdlib).
import urllib.parse
assert module.urlsplit is urllib.parse.urlsplit
@@ -0,0 +1,315 @@
"""Claim route tests — POST /v1/employers/claims (Task 2-1-03, REQ-10-002).
MH-2b pinned here: the exact evidence dict
{account_email_domain, entity_website_domain, match} computed AT CLAIM
TIME, stored as provenance JSON on the claim row, returned with the
pending claim. The entity must exist in the marketplace store (404);
the account must not already hold an entity (409 honest); one pending
claim per entity (409 — G-316's index backstops the race); the route is
session-gated (401 unauthenticated). Auto-approval is deferred (D-089).
REAL scrypt throughout (register mints a real hash — the conftest
fixtures handle limiter resets).
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer
from .conftest import (
TEST_EMAIL,
bearer,
make_client,
register,
)
ENTITY_ID = "emp-openai"
def seed_employer(client: TestClient, website: str = "https://example.com/openai") -> None:
"""Upsert a marketplace EmployerRow through the app's own store
(app.state.marketplace_store — the same DI the route composes)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=ENTITY_ID,
source="seed-corpus",
external_id=ENTITY_ID,
name="OpenAI Labs",
website=website,
)
)
def claim(client: TestClient, token: str, employer_id: str = ENTITY_ID):
return client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
class TestClaimRoute:
def test_claim_pending_with_exact_evidence(self, client: TestClient) -> None:
"""Claim → 201 pending with the EXACT evidence dict (MH-2b):
the R-65 example.com corpus makes match=false corpus-wide."""
seed_employer(client)
token = register(client)["token"]
resp = claim(client, token)
assert resp.status_code == 201, resp.text
claim_row = resp.json()
assert claim_row["status"] == "pending"
assert claim_row["employer_id"] == ENTITY_ID
assert claim_row["evidence"] == {
"account_email_domain": "nextcraft.example",
"entity_website_domain": "example.com",
"match": False,
}
assert claim_row["decided_by"] is None
assert claim_row["decided_at"] is None
assert claim_row["account_id"]
def test_evidence_true_on_matching_domain(
self, client: TestClient
) -> None:
"""A matching website domain (R-65's registrable case) — the
evidence signal is ADVISORY: match=true still files a PENDING
claim (auto-approval deferred, D-089/A-304)."""
seed_employer(client, website="https://nextcraft.example/careers")
token = register(client)["token"]
resp = claim(client, token)
assert resp.status_code == 201, resp.text
assert resp.json()["evidence"] == {
"account_email_domain": "nextcraft.example",
"entity_website_domain": "nextcraft.example",
"match": True,
}
assert resp.json()["status"] == "pending"
def test_unknown_entity_404(self, client: TestClient) -> None:
token = register(client)["token"]
resp = claim(client, token, employer_id="emp-does-not-exist")
assert resp.status_code == 404
def test_account_already_holds_entity_409(
self, client: TestClient
) -> None:
"""One account : one entity — an owner claiming a SECOND
(free) entity gets the honest 409. The ownership binds via the
store's decide_claim (the same transition the admin route
drives in Wave 2-2)."""
seed_employer(client)
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
token = register(client)["token"]
first = claim(client, token)
assert first.status_code == 201
# Operator approve (the store path the 2-2 route composes).
decided = store.decide_claim(
first.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
# A second, otherwise-free entity — still refused.
store2 = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store2.upsert_employer(
Employer(
id="emp-other",
source="seed-corpus",
external_id="emp-other",
name="Other Labs",
website="https://example.com/other",
)
)
resp = claim(client, token, employer_id="emp-other")
assert resp.status_code == 409
def test_second_pending_claim_same_entity_409(
self, client: TestClient
) -> None:
"""One PENDING claim per entity — the check-first 409 fast
path, by ANOTHER account (the same account's duplicate also
409s; the other-account arm is the stronger pin)."""
seed_employer(client)
first_token = register(client)["token"]
assert claim(client, first_token).status_code == 201
second_token = register(client, email="other@example.com")["token"]
resp = claim(client, second_token)
assert resp.status_code == 409
def test_unauthenticated_401(self, client: TestClient) -> None:
seed_employer(client)
resp = client.post(
"/v1/employers/claims", json={"employer_id": ENTITY_ID}
)
assert resp.status_code == 401
def test_invalid_token_401(self, client: TestClient) -> None:
seed_employer(client)
resp = client.post(
"/v1/employers/claims",
json={"employer_id": ENTITY_ID},
headers=bearer("not-a-real-token"),
)
assert resp.status_code == 401
def test_claim_evidence_persisted_as_provenance(
self, client: TestClient
) -> None:
"""The evidence snapshot rides the claim ROW (stored at claim
time, never recomputed on read — D-089) + a claim.submitted
audit event lands."""
from sqlalchemy import text
seed_employer(client)
token = register(client)["token"]
resp = claim(client, token)
assert resp.status_code == 201
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
with store._engine.connect() as conn:
evidence = conn.execute(
text("SELECT evidence FROM employer_claims")
).scalar_one()
audit = conn.execute(
text(
"SELECT kind, name FROM employer_audit_events "
"WHERE kind = 'claim'"
)
).fetchall()
assert evidence == (
'{"account_email_domain": "nextcraft.example", '
'"entity_website_domain": "example.com", "match": false}'
)
assert audit == [("claim", "claim.submitted")]
def test_empty_employer_id_422(self, client: TestClient) -> None:
token = register(client)["token"]
resp = client.post(
"/v1/employers/claims", json={"employer_id": ""}, headers=bearer(token)
)
assert resp.status_code == 422
class TestRacedInsert:
def test_raced_pending_claim_same_honest_409(
self, tmp_path
) -> None:
"""G-316 at the route: a second pending row raced past the
check-first read is refused by the partial UNIQUE index — the
surfaced IntegrityError maps to the SAME honest 409, never a
duplicate pending row (proven by inserting the rival row
directly through the store, bypassing the route's check)."""
with make_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
# The rival pending claim lands BETWEEN this route's
# check-first read and its insert only under a race; a
# direct store insert simulates the raced writer.
rival_account, _ = employer_store.insert_account(
email="rival@example.com",
password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA",
)
employer_store.insert_claim(
account_id=rival_account.id,
employer_id=ENTITY_ID,
evidence={},
)
resp = claim(client, token)
assert resp.status_code == 409
pending = employer_store.list_claims(status="pending")
assert len(pending) == 1 # never a duplicate pending row
class TestClaimDiscipline:
def test_marketplace_store_via_di_never_imported(
self,
) -> None:
"""Territory rule: the route composes the marketplace store
via app.state DI (get_marketplace_store) — api/employers.py
never CONSTRUCTS a store. P4 (Task 4-1-02): the write routes
compose the admin routes' own imports (InvalidTransition,
MarketplaceStore annotations — the marketplace_admin.py:32
precedent), so the pin asserts the CONSTRUCTION ban, not the
import ban: no SQLiteMarketplaceStore anywhere in the
module, the DEP composed on every store touch."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "SQLiteMarketplaceStore" not in source
assert "get_marketplace_store" in source
def test_no_learner_gate_imported(self) -> None:
"""D-097: no learner gate composed on any employer route —
pinned at the IMPORT sites (the api.identity imports are what
compose gates; docstring mentions are copy, not code)."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "from .identity import" not in source
assert "from ..identity" not in source
assert "learner_allowlist" not in source
def test_route_order_entity_check_before_owner_check(
self, tmp_path
) -> None:
"""The 404 (unknown entity) wins over the 409 (already an
owner): the entity is resolved first — an owner probing an
unknown id learns nothing about ownership state."""
with make_client(tmp_path) as client:
token = register(client)["token"]
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
account = store.get_account_by_email(TEST_EMAIL)
assert account is not None
with store._engine.connect() as conn:
from sqlalchemy import text
conn.execute(
text(
"UPDATE employer_accounts SET employer_id = "
"'emp-owned' WHERE id = :acct"
),
{"acct": account.id},
)
conn.commit()
resp = claim(client, token, employer_id="emp-unknown")
assert resp.status_code == 404
def test_no_learner_gate_composed(self) -> None:
"""D-097: no learner gate (require_verified_age/allowlist)
composed on any employer route — pinned at the import sites
(docstring copy is not code)."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "from .identity import" not in source
assert "from ..identity" not in source
assert "learner_allowlist" not in source
def test_claim_route_registered(tmp_path) -> None:
"""The claims_router is mounted (main.py wiring pin): the route
exists in the OpenAPI schema — POST /v1/employers/claims with the
session-gated claim body (the _IncludedRouter wrappers hide
APIRoutes from app.routes in this starlette, so the schema is the
honest registry)."""
with make_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
path = schema["paths"]["/v1/employers/claims"]
assert set(path) == {"post"}
body_schema = path["post"]["requestBody"]["content"][
"application/json"
]["schema"]
# $ref → ClaimEmployerRequest, whose only required field is
# employer_id.
assert body_schema["$ref"].endswith("ClaimEmployerRequest")
request = schema["components"]["schemas"]["ClaimEmployerRequest"]
assert request["required"] == ["employer_id"]
assert request["properties"]["employer_id"]["minLength"] == 1
@@ -0,0 +1,604 @@
"""Admin claim review/operator-route tests (Task 2-2-01, REQ-10-002, MH-2a/2d).
Pinned: approve → account.employer_id set + audit row + claim approved;
reject frees the entity (a new claim from ANOTHER account succeeds);
decided twice → 409 honest (one decision audit row — no double
activation); the entity-owned arm surfaces as 409, never an overwrite;
fail-closed 403 on ALL five routes (no token / wrong token /
unset-config / cross-principal session token); a 403'd decision
writes NOTHING; the audit query returns claim decisions with operator
provenance; the five routes are mounted + OpenAPI-visible. The
admin-token pattern is the billing one (X-Admin-Token + a SecretStr'd
Settings knob, tests/billing/test_lifecycle_admin.py:26-63).
REAL scrypt throughout (register mints a real hash; the conftest
autouse fixture resets the auth limiter module states — the admin
routes add no limiter state of their own).
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.marketplace.base import Employer
from .conftest import TEST_EMAIL, make_client, register
ADMIN_TOKEN = "test-claims-admin"
ENTITY_ID = "emp-openai"
#: The five-route fail-closed matrix surface (R-56): the three GETs
#: plus the two decision POSTs (formatted with a claim id).
ROUTES = (
"/v1/employers/admin/accounts",
"/v1/employers/admin/claims",
"/v1/employers/admin/audit",
)
DECISION_ROUTES = (
"/v1/employers/admin/claims/{claim_id}/approve",
"/v1/employers/admin/claims/{claim_id}/reject",
)
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
@contextmanager
def make_admin_client(
tmp_path: Path, admin_token: str | None = ADMIN_TOKEN
) -> Iterator[TestClient]:
"""The full app with the admin token CONFIGURED (the billing
pattern: SecretStr knob; admin_token=None exercises the UNSET
fail-closed class — the empty SecretStr)."""
with make_client(tmp_path, admin_token=SecretStr(admin_token or "")) as client:
yield client
def seed_employer(client: TestClient, employer_id: str = ENTITY_ID, website: str = "") -> None:
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=employer_id,
source="seed-corpus",
external_id=employer_id,
name="OpenAI Labs",
website=website or f"https://example.com/{employer_id}",
)
)
def claim(client: TestClient, token: str, employer_id: str = ENTITY_ID) -> dict:
"""Session-gated claim through the REAL route (409/404 arms raise
— the callers pin the happy path)."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()
def decide(client: TestClient, claim_id: str, arm: str = "approve") -> TestClient:
"""Convenience: POST the decision with the configured token; the
CLIENT returns for status asserts."""
client.post(
f"/v1/employers/admin/claims/{claim_id}/{arm}",
headers=admin_headers(),
)
return client
class TestApprove:
def test_approve_sets_ownership_and_audit(self, tmp_path: Path) -> None:
"""MH-2a: approve → claim approved with operator provenance +
account.employer_id set (read through /me — the same store the
route wrote) + the audit row with operator provenance."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
resp = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert resp.status_code == 200, resp.text
decided = resp.json()["claim"]
assert decided["status"] == "approved"
assert decided["id"] == row["id"]
assert decided["decided_by"] == "operator"
assert decided["decided_at"] is not None
me = client.get(
"/v1/employers/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
assert me.status_code == 200
body = me.json()
assert body["claimed_employer_id"] == ENTITY_ID
assert body["account"]["employer_id"] == ENTITY_ID
audit = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
events = audit.json()["events"]
approve = [e for e in events if e["name"] == "claim.approved"]
assert len(approve) == 1
assert approve[0]["operator"] == "operator"
assert approve[0]["detail"]["claim_id"] == row["id"]
assert approve[0]["detail"]["employer_id"] == ENTITY_ID
assert approve[0]["account_id"]
def test_approve_pops_pending_queue(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="q@example.com")["token"]
row = claim(client, token)
pending = client.get(
"/v1/employers/admin/claims?status=pending",
headers=admin_headers(),
)
assert pending.json()["count"] == 1
assert (
client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
).status_code
== 200
)
pending = client.get(
"/v1/employers/admin/claims?status=pending",
headers=admin_headers(),
)
assert pending.json()["count"] == 0
approved = client.get(
"/v1/employers/admin/claims?status=approved",
headers=admin_headers(),
)
assert approved.json()["count"] == 1
class TestReject:
def test_reject_frees_entity_for_new_claim(self, tmp_path: Path) -> None:
"""Reject → decided + audited; the entity is FREE — a new
claim from ANOTHER account succeeds; the rejected account
owns nothing."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
first_token = register(client, email="first@example.com")["token"]
row = claim(client, first_token)
resp = client.post(
f"/v1/employers/admin/claims/{row['id']}/reject",
headers=admin_headers(),
)
assert resp.status_code == 200, resp.text
rejected = resp.json()["claim"]
assert rejected["status"] == "rejected"
assert rejected["decided_by"] == "operator"
second_token = register(client, email="second@example.com")["token"]
second = client.post(
"/v1/employers/claims",
json={"employer_id": ENTITY_ID},
headers={"Authorization": f"Bearer {second_token}"},
)
assert second.status_code == 201, second.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
first = store.get_account_by_email("first@example.com")
assert first is not None and first.employer_id is None
audit = client.get(
"/v1/employers/admin/audit?name=claim.rejected",
headers=admin_headers(),
)
assert audit.json()["count"] == 1
assert audit.json()["events"][0]["operator"] == "operator"
class TestDecideDiscipline:
def test_decide_twice_409_honest(self, tmp_path: Path) -> None:
"""Deciding a DECIDED claim → 409 for BOTH arms (the store's
not-pending guard); exactly ONE decision audit row."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="t@example.com")["token"]
row = claim(client, token)
first = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert first.status_code == 200
again = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert again.status_code == 409
assert again.json()["detail"] == "claim is already decided"
flip = client.post(
f"/v1/employers/admin/claims/{row['id']}/reject",
headers=admin_headers(),
)
assert flip.status_code == 409
audit = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
decisions = [
e
for e in audit.json()["events"]
if e["name"] in ("claim.approved", "claim.rejected")
]
assert len(decisions) == 1
def test_unknown_claim_404(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
for route in DECISION_ROUTES:
resp = client.post(
route.format(claim_id="clm-does-not-exist"),
headers=admin_headers(),
)
assert resp.status_code == 404, route
def test_approve_account_holds_entity_409(self, tmp_path: Path) -> None:
"""The route arm of one-account-one-entity: a pending claim
whose account ALREADY holds an entity (the account became an
owner on another entity after filing) — approve surfaces the
store's hand-raised guard as the honest 409, never an
overwrite. The rival claim is inserted directly through the
store seam (a second pending claim on a DIFFERENT free entity
whose account is the owner)."""
with make_admin_client(tmp_path) as client:
seed_employer(client, "emp-a")
seed_employer(client, "emp-b")
token = register(client, email="owner@example.com")["token"]
first = claim(client, token, "emp-a")
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(first["id"], decision="approved", operator="op")
assert decided is not None
# The now-owner files a second claim on a free entity via
# the store seam (the route would 409 the owner first).
row, created = store.insert_claim(
account_id=decided.account_id,
employer_id="emp-b",
evidence={},
)
assert created
resp = client.post(
f"/v1/employers/admin/claims/{row.id}/approve",
headers=admin_headers(),
)
assert resp.status_code == 409
assert resp.json()["detail"] == ("the claim's account already holds an employer entity")
# Nothing was overwritten: the account still owns emp-a
# only, and the claim row stays pending (undecided).
account = store.get_account(decided.account_id)
assert account is not None
assert account.employer_id == "emp-a"
still = store.get_claim(row.id)
assert still is not None and still.status == "pending"
def test_entity_owned_by_another_409_no_overwrite(self, tmp_path: Path) -> None:
"""The one-APPROVED-owner arm at the route: a pending claim on
an entity whose owner is ANOTHER account (the ownership raced
past the claim route's checks) — the approve flush surfaces the
employer_accounts.employer_id UNIQUE as the honest 409; the
EXISTING owner is never displaced. The rival ownership is set
directly on the account through the store seam (a second
PENDING claim on the entity is impossible — the partial index
refuses it, so the claim-route race is not the reachable seam;
the OWNERSHIP race is)."""
with make_admin_client(tmp_path) as client:
seed_employer(client, "emp-race")
token = register(client, email="racer@example.com")["token"]
row = claim(client, token, "emp-race")
# A rival account becomes the entity's owner through the
# store seam AFTER the claim was filed (direct account
# UPDATE — the same write decide_claim performs, bypassing
# its guards: the seam simulates an ownership that raced).
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
rival, _ = store.insert_account(
email="rival-owner@example.com",
password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA",
)
from sqlalchemy import text
with store._engine.connect() as conn: # test seam into the store's engine
conn.execute(
text("UPDATE employer_accounts SET employer_id = :entity WHERE id = :acct"),
{"entity": "emp-race", "acct": rival.id},
)
conn.commit()
# Now the OPERATOR approves the pending claim on the same
# entity: the UNIQUE refuses — the honest 409, the rival
# keeps the ownership, the claim row stays pending.
resp = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert resp.status_code == 409
assert resp.json()["detail"] == ("the employer entity is already owned")
owner = store.get_account(rival.id)
assert owner is not None and owner.employer_id == "emp-race"
loser = store.get_account_by_email("racer@example.com")
assert loser is not None and loser.employer_id is None
still = store.get_claim(row["id"])
assert still is not None and still.status == "pending"
class TestAccountsSurface:
def test_accounts_list_columns_and_no_hash(self, tmp_path: Path) -> None:
"""The accounts list: email, claimed entity, created_at, the
UNVERIFIED marker — and password_hash NEVER on the wire."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
rows = client.get("/v1/employers/admin/accounts", headers=admin_headers())
assert rows.status_code == 200
body = rows.json()
assert body["count"] == 1
account = body["accounts"][0]
assert account["email"] == TEST_EMAIL
assert account["employer_id"] is None # pending — unclaimed
assert account["created_at"]
assert account["email_verified"] is False
assert "password_hash" not in account
assert "password" not in account
decide(client, row["id"])
rows = client.get("/v1/employers/admin/accounts", headers=admin_headers())
assert rows.json()["accounts"][0]["employer_id"] == ENTITY_ID
class TestClaimsQueueSurface:
def test_queue_carries_advisory_evidence(self, tmp_path: Path) -> None:
"""The queue renders the stored evidence snapshot + the
domain-match signal as ADVISORY (A-304/R-65: match=false is
the example.com corpus norm — advisory, never verification);
decided state None while pending. The snapshot is the STORE's
claim-time provenance, never recomputed on queue read."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
queue = client.get("/v1/employers/admin/claims", headers=admin_headers())
assert queue.status_code == 200
entry = queue.json()["claims"][0]
assert entry["id"] == row["id"]
assert entry["status"] == "pending"
assert entry["evidence"] == {
"account_email_domain": "nextcraft.example",
"entity_website_domain": "example.com",
"match": False,
}
assert entry["decided_by"] is None
assert entry["decided_at"] is None
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
stored = store.get_claim(row["id"])
assert stored is not None
assert dict(stored.evidence) == entry["evidence"]
def test_status_facet(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="facet@example.com")["token"]
claim(client, token)
for facet, expected in (
("pending", 1),
("approved", 0),
("rejected", 0),
):
rows = client.get(
f"/v1/employers/admin/claims?status={facet}",
headers=admin_headers(),
)
assert rows.status_code == 200
assert rows.json()["count"] == expected
class TestAuditTrail:
def test_audit_query_provenance(self, tmp_path: Path) -> None:
"""The audit query returns claim decisions with operator
provenance: kind/account/employer/name filters + the
submitted → approved chain, decided_by/decided_at joined via
detail.claim_id."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
assert (
client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
).status_code
== 200
)
claims = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
assert claims.status_code == 200
names = [e["name"] for e in claims.json()["events"]]
assert names == ["claim.approved", "claim.submitted"]
approved = client.get(
"/v1/employers/admin/audit?name=claim.approved",
headers=admin_headers(),
)
assert approved.json()["count"] == 1
event = approved.json()["events"][0]
assert event["operator"] == "operator"
assert event["detail"]["claim_id"] == row["id"]
assert event["detail"]["employer_id"] == ENTITY_ID
by_entity = client.get(
f"/v1/employers/admin/audit?employer_id={ENTITY_ID}",
headers=admin_headers(),
)
assert by_entity.json()["count"] == 2
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
account = store.get_account_by_email(TEST_EMAIL)
assert account is not None
by_account = client.get(
f"/v1/employers/admin/audit?account_id={account.id}",
headers=admin_headers(),
)
# register + claim.submitted + claim.approved all carry the
# account's id (register stamps account_id too).
assert by_account.json()["count"] == 3
# The submitted event's detail snapshots the advisory
# evidence_match signal (P2-SEC: advisory, claim-time).
submitted = client.get(
"/v1/employers/admin/audit?name=claim.submitted",
headers=admin_headers(),
)
assert submitted.json()["events"][0]["detail"]["evidence_match"] is False
# The DECIDED claim carries decided_by/decided_at (the
# operator provenance the decision events reference).
decided_row = store.get_claim(row["id"])
assert decided_row is not None
assert decided_row.decided_by == "operator"
assert decided_row.decided_at is not None
def test_audit_no_filters_returns_all(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="all@example.com")["token"]
claim(client, token)
everything = client.get("/v1/employers/admin/audit", headers=admin_headers())
assert everything.status_code == 200
names = {e["name"] for e in everything.json()["events"]}
assert names == {"register", "claim.submitted"}
class TestFailClosed:
"""MH-2d: all five operator routes fail-closed — no token,
wrong token, the unset-config class (an empty admin_token 403s
EVERY request even with a candidate), and the cross-principal
class (a VALID employer session token is NOT an admin token)."""
def test_all_five_no_token_403(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
for route in ROUTES:
assert client.get(route).status_code == 403, route
for route in DECISION_ROUTES:
assert client.post(route.format(claim_id="clm-x")).status_code == 403, route
def test_all_five_wrong_token_403(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
bad = admin_headers("wrong-token")
for route in ROUTES:
assert client.get(route, headers=bad).status_code == 403, route
for route in DECISION_ROUTES:
assert (
client.post(route.format(claim_id="clm-x"), headers=bad).status_code == 403
), route
def test_unset_token_fails_closed_on_all_five(self, tmp_path: Path) -> None:
"""The R-56 matrix's unset-config arm: AI_ADMIN_TOKEN unset →
every route 403s EVEN WITH a candidate token."""
with make_admin_client(tmp_path, admin_token=None) as client:
candidate = admin_headers("anything")
for route in ROUTES:
assert client.get(route, headers=candidate).status_code == 403, route
for route in DECISION_ROUTES:
assert (
client.post(route.format(claim_id="clm-x"), headers=candidate).status_code
== 403
), route
def test_employer_session_token_is_not_admin(self, tmp_path: Path) -> None:
"""Token namespaces cannot collide (D-090): a VALID employer
session token in the admin header fails the admin gate — the
principal classes stay disjoint (one class fails the other's
gate)."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
session_token = register(client)["token"]
swapped = {"X-Admin-Token": session_token}
for route in ROUTES:
assert client.get(route, headers=swapped).status_code == 403, route
for route in DECISION_ROUTES:
assert (
client.post(route.format(claim_id="clm-x"), headers=swapped).status_code == 403
), route
def test_decisions_fail_closed_leave_no_side_effects(self, tmp_path: Path) -> None:
"""A 403'd decision writes NOTHING: the claim stays pending,
no decision audit row (the gate runs before any store call)."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="s@example.com")["token"]
row = claim(client, token)
for route in DECISION_ROUTES:
assert client.post(route.format(claim_id=row["id"])).status_code == 403, route
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
still = store.get_claim(row["id"])
assert still is not None and still.status == "pending"
audit = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
# claim.submitted only — no decision rows from the 403s.
names = [e["name"] for e in audit.json()["events"]]
assert names == ["claim.submitted"]
class TestMounted:
def test_all_five_routes_openapi_visible(self, tmp_path: Path) -> None:
"""The operator routes are mounted + OpenAPI-visible (the
_IncludedRouter wrappers hide APIRoutes from app.routes in
this starlette — the schema is the honest registry)."""
with make_admin_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
paths = schema["paths"]
for route in ROUTES:
assert route in paths, route
assert set(paths[route]) == {"get"}, route
for route in DECISION_ROUTES:
formatted = route.format(claim_id="{claim_id}")
assert formatted in paths, formatted
assert set(paths[formatted]) == {"post"}, formatted
def test_accounts_contract_no_hash_field(self, tmp_path: Path) -> None:
"""Wire-contract pin: AdminEmployerAccount exposes exactly the
D-094 columns — password_hash is structurally absent."""
with make_admin_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
account = schema["components"]["schemas"]["AdminEmployerAccount"]
assert set(account["properties"]) == {
"id",
"email",
"employer_id",
"created_at",
"email_verified",
}
# D-097: the marker is a constant False on the wire.
assert account["properties"]["email_verified"]["default"] is False
def test_no_learner_gate_composed() -> None:
"""D-097: no learner gate composed on the operator routes —
pinned at the import sites (docstring copy is not code)."""
import inspect
from ai_service.api import employers_admin
source = inspect.getsource(employers_admin)
assert "from .identity import" not in source
assert "from ..identity" not in source
assert "learner_allowlist" not in source
def test_require_admin_imported_not_reimplemented() -> None:
"""D-094: the guard is IMPORTED from marketplace_admin (the
billing_admin.py:27 precedent) — no compare/digest in territory."""
import inspect
from ai_service.api import employers_admin
from ai_service.api.marketplace_admin import require_admin
source = inspect.getsource(employers_admin)
assert "from .marketplace_admin import require_admin" in source
assert "compare_digest" not in source
assert employers_admin.require_admin is require_admin
@@ -0,0 +1,407 @@
"""EmployerStore claim-method tests (Task 2-1-02, REQ-10-002).
G-316 pins: the partial UNIQUE index uq_pending_claim_per_entity lands
in sqlite_master on fresh DBs (create_all emits it — tests use fresh
tmp_path DBs, never a P1-era file); a raced second pending insert →
IntegrityError (the caller's 409), never a duplicate pending row.
MH-2c pins: one PENDING claim per entity + one APPROVED owner per
entity + rejection frees the entity. decided_by/decided_at provenance
pinned on both arms.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session
from ai_service.employers.store import SQLiteEmployerStore
PASSWORD_HASH = "scrypt$16384$8$1$c2FsdA$aGFzaA" # self-describing string
EVIDENCE = {
"account_email_domain": "openai.example",
"entity_website_domain": "example.com",
"match": False,
}
def make_store(tmp_path: Path) -> SQLiteEmployerStore:
return SQLiteEmployerStore(db_path=tmp_path / "claims-store.db")
def add_account(
store: SQLiteEmployerStore, email: str
) -> str:
row, _ = store.insert_account(email=email, password_hash=PASSWORD_HASH)
return row.id
def add_claim(
store: SQLiteEmployerStore,
*,
account_id: str,
employer_id: str,
evidence: dict | None = None,
) -> tuple[str, bool]:
row, created = store.insert_claim(
account_id=account_id,
employer_id=employer_id,
evidence=evidence if evidence is not None else EVIDENCE,
)
return row.id, created
def count_pending(
store: SQLiteEmployerStore, employer_id: str
) -> int:
with Session(store._engine) as session:
return int(
session.execute(
text(
"SELECT COUNT(*) FROM employer_claims "
"WHERE employer_id = :eid AND status = 'pending'"
),
{"eid": employer_id},
).scalar_one()
)
class TestPartialIndex:
def test_uq_pending_claim_per_entity_in_sqlite_master(
self, tmp_path: Path
) -> None:
"""G-316 pinned: create_all emits the partial UNIQUE index —
check-first alone is TOCTOU; the index makes the guard
structural."""
store = make_store(tmp_path)
with store._engine.connect() as conn:
row = conn.execute(
text(
"SELECT name, sql FROM sqlite_master "
"WHERE type='index' "
"AND tbl_name='employer_claims' "
"AND name='uq_pending_claim_per_entity'"
)
).fetchone()
assert row is not None
assert "UNIQUE INDEX" in str(row[1])
assert "WHERE status='pending'" in str(row[1])
class TestInsertClaim:
def test_first_claim_created_true_pending(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, created = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
assert created is True
row = store.get_claim(claim_id)
assert row is not None
assert row.status == "pending"
assert row.account_id == account_id
assert row.employer_id == "emp-1"
assert row.evidence == EVIDENCE
assert row.decided_by is None and row.decided_at is None
def test_second_pending_claim_rejected_fast_path(
self, tmp_path: Path
) -> None:
"""MH-2c arm 1: one PENDING claim per entity — the check-first
409 fast path (the insert_application precedent)."""
store = make_store(tmp_path)
first = add_account(store, "first@example.com")
second = add_account(store, "second@example.com")
first_id, created_first = add_claim(
store, account_id=first, employer_id="emp-1"
)
second_row, created_second = store.insert_claim(
account_id=second, employer_id="emp-1", evidence=EVIDENCE
)
assert created_first is True
assert created_second is False
assert second_row.id == first_id # the EXISTING row rides
assert count_pending(store, "emp-1") == 1
def test_raced_second_pending_insert_surfaces_integrity_error(
self, tmp_path: Path
) -> None:
"""G-316 pinned: a raced insert (check-first read misses, the
index refuses the write) SURFACES IntegrityError — the caller
maps it to the SAME honest 409; never a duplicate pending row.
Raw SQL via a direct Session simulates the race window after
the check-first read."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
add_claim(store, account_id=account_id, employer_id="emp-1")
with Session(store._engine) as session:
with pytest.raises(IntegrityError):
session.execute(
text(
"INSERT INTO employer_claims "
"(id, account_id, employer_id, status, evidence, "
"created_at) "
"VALUES ('clm-raced', :acct, 'emp-1', 'pending', "
"'{}', :now)"
),
{"acct": account_id, "now": datetime.now(UTC)},
)
session.flush()
session.rollback()
assert count_pending(store, "emp-1") == 1
def test_evidence_stored_as_provenance_json(
self, tmp_path: Path
) -> None:
"""The advisory evidence snapshot rides the claim row (D-089
— computed at claim time, never recomputed on read)."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store,
account_id=account_id,
employer_id="emp-1",
evidence={
"account_email_domain": "acme.com",
"entity_website_domain": "acme.com",
"match": True,
},
)
with Session(store._engine) as session:
stored = session.execute(
text(
"SELECT evidence FROM employer_claims WHERE id = :cid"
),
{"cid": claim_id},
).scalar_one()
# The sa_type=JSON column round-trips through the ORM as a
# dict (insert_claim's own read path proves the stored shape);
# raw SQL returns the serialized JSON string — parse it.
assert json.loads(stored) == {
"account_email_domain": "acme.com",
"entity_website_domain": "acme.com",
"match": True,
}
# And the ORM read path hands the dict back.
row = store.get_claim(claim_id)
assert row is not None
assert row.evidence == {
"account_email_domain": "acme.com",
"entity_website_domain": "acme.com",
"match": True,
}
class TestDecideClaim:
def test_approve_sets_employer_id_and_audit_row(
self, tmp_path: Path
) -> None:
"""MH-2c/MH-2a store half: approve → employer_accounts.
employer_id set + the `claim` audit row with operator
provenance."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
decided = store.decide_claim(
claim_id,
decision="approved",
operator="op-1",
now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC),
)
assert decided is not None
assert decided.status == "approved"
assert decided.decided_by == "op-1"
assert decided.decided_at == datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
account = store.get_account(account_id)
assert account is not None
assert account.employer_id == "emp-1"
with Session(store._engine) as session:
audit = session.execute(
text(
"SELECT kind, name, operator, detail "
"FROM employer_audit_events ORDER BY id"
)
).fetchall()
assert len(audit) == 1
kind, name, operator, detail = audit[0]
# Raw SQL hands the serialized JSON string back — parse it.
assert kind == "claim"
assert name == "claim.approved"
assert operator == "op-1"
assert json.loads(detail) == {
"claim_id": claim_id,
"employer_id": "emp-1",
"operator": "op-1",
}
def test_reject_sets_provenance_keeps_account_free(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
decided = store.decide_claim(
claim_id,
decision="rejected",
operator="op-2",
now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC),
)
assert decided is not None
assert decided.status == "rejected"
assert decided.decided_by == "op-2"
assert decided.decided_at == datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
account = store.get_account(account_id)
assert account is not None
assert account.employer_id is None # rejection never binds
def test_rejection_frees_entity_for_new_claim(
self, tmp_path: Path
) -> None:
"""MH-2c arm 3: rejection frees the entity — a new claim on the
same entity succeeds after a reject."""
store = make_store(tmp_path)
first = add_account(store, "first@example.com")
second = add_account(store, "second@example.com")
first_id, _ = add_claim(
store, account_id=first, employer_id="emp-1"
)
store.decide_claim(
first_id, decision="rejected", operator="op-1"
)
second_id, created = add_claim(
store, account_id=second, employer_id="emp-1"
)
assert created is True
assert second_id != first_id
row = store.get_claim(second_id)
assert row is not None and row.status == "pending"
def test_second_approve_entity_already_owned_surfaces_integrity_error(
self, tmp_path: Path
) -> None:
"""MH-2c arm 2: one APPROVED owner per entity. Account A's
claim on emp-1 approves (A owns emp-1); account B then claims
the now-unblocked entity and the second approve collides on
employer_accounts.employer_id UNIQUE — B's UPDATE would bind a
second owner to emp-1. The raw-SQL arm below pins the
STRUCTURAL constraint itself; the decide_claim arm (next test)
pins the store path."""
store = make_store(tmp_path)
first = add_account(store, "first@example.com")
second = add_account(store, "second@example.com")
first_id, _ = add_claim(
store, account_id=first, employer_id="emp-1"
)
store.decide_claim(first_id, decision="approved", operator="op-1")
# B claims the entity after the pending window closed.
second_id, _ = add_claim(
store, account_id=second, employer_id="emp-1"
)
with pytest.raises(IntegrityError):
store.decide_claim(second_id, decision="approved", operator="op-2")
# The refused approve bound nothing: B still holds no entity,
# A still owns emp-1, and the second claim stays pending.
a = store.get_account(first)
b = store.get_account(second)
assert a is not None and a.employer_id == "emp-1"
assert b is not None and b.employer_id is None
still = store.get_claim(second_id)
assert still is not None and still.status == "pending"
assert count_pending(store, "emp-1") == 1
def test_decide_unknown_claim_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert (
store.decide_claim(
"clm-nope", decision="approved", operator="op-1"
)
is None
)
def test_decide_already_decided_claim_surfaces_integrity_error(
self, tmp_path: Path
) -> None:
"""Deciding a decided claim refuses — the hand-raised guard
(IntegrityError shape, the caller's honest 409)."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
store.decide_claim(claim_id, decision="rejected", operator="op-1")
with pytest.raises(IntegrityError):
store.decide_claim(
claim_id, decision="approved", operator="op-1"
)
def test_approve_when_account_already_holds_entity_integrity_error(
self, tmp_path: Path
) -> None:
"""One ACCOUNT : one entity — approving B's claim when B
already holds emp-2 refuses (B's employer_id is set; the
hand-raised guard surfaces IntegrityError)."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
first_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-2"
)
store.decide_claim(first_id, decision="approved", operator="op-1")
second_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-9"
)
with pytest.raises(IntegrityError):
store.decide_claim(
second_id, decision="approved", operator="op-1"
)
def test_invalid_decision_rejected(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
with pytest.raises(ValueError, match="invalid decision"):
store.decide_claim(
claim_id, decision="maybe", operator="op-1"
)
class TestListClaims:
def test_status_filter_and_newest_first(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
a = add_account(store, "a@example.com")
b = add_account(store, "b@example.com")
add_claim(store, account_id=a, employer_id="emp-1")
add_claim(store, account_id=b, employer_id="emp-2")
pending = store.list_claims(status="pending")
assert len(pending) == 2
assert all(row.status == "pending" for row in pending)
# Newest first (created_at DESC).
first_created = pending[0].created_at
second_created = pending[1].created_at
assert first_created is not None and second_created is not None
assert first_created >= second_created
# Approve one, then the filter arms.
store.decide_claim(pending[0].id, decision="approved", operator="op-1")
assert len(store.list_claims(status="pending")) == 1
approved = store.list_claims(status="approved")
assert len(approved) == 1
assert approved[0].status == "approved"
assert len(store.list_claims()) == 2 # no filter → all
def test_get_claim_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert store.get_claim("clm-nope") is None
@@ -0,0 +1,169 @@
"""scrypt KDF tests (Task 1-1-03, D-086/R-55, REQ-10-001).
REAL KDF, no fake-hash path (D-096): every accept/reject below
performs actual derivations (~2 × 50 ms — inside the R-55/R-64
budget). Tests assert accept/reject BEHAVIOR only — never hash bytes
(a byte pin would break the row format's evolution guarantee).
"""
from __future__ import annotations
import base64
import hashlib
from ai_service.employers.passwords import (
SCRYPT_DKLEN,
SCRYPT_N,
SCRYPT_P,
SCRYPT_R,
hash_password,
verify_password,
)
PASSWORD = "correct horse battery staple"
WRONG = "Tr0ub4dor&3"
class TestParamsPinned:
"""D-086: the KDF parameters are module constants, pinned by test
(the PAST_DUE_GRACE_DAYS shape)."""
def test_scrypt_constants_pinned(self) -> None:
assert SCRYPT_N == 2**14
assert SCRYPT_N == 16384
assert SCRYPT_R == 8
assert SCRYPT_P == 1
assert SCRYPT_DKLEN == 32
def test_stored_format_carries_the_pinned_params(self) -> None:
stored = hash_password(PASSWORD)
parts = stored.split("$")
assert len(parts) == 6
assert parts[0] == "scrypt"
assert parts[1] == "16384" # N — the R-55 probe cost point
assert parts[2] == "8" # r
assert parts[3] == "1" # p
# salt + hash are base64; the derived length rides the row's
# tail (dklen=32 bytes → 44 base64 chars, no padding surprises).
assert len(base64.b64decode(parts[4])) == 16 # salt bytes
assert len(base64.b64decode(parts[5])) == SCRYPT_DKLEN
class TestHashVerify:
"""REAL scrypt — accept/reject behavior only (never hash bytes)."""
def test_hash_then_verify_accepts(self) -> None:
stored = hash_password(PASSWORD)
assert verify_password(stored, PASSWORD) is True
def test_wrong_password_rejected(self) -> None:
stored = hash_password(PASSWORD)
assert verify_password(stored, WRONG) is False
def test_two_hashes_of_same_password_differ(self) -> None:
"""Random per-password salt: two hashes of ONE password are
distinct stored strings, and each verifies against the same
password (the salt rides the row)."""
first = hash_password(PASSWORD)
second = hash_password(PASSWORD)
assert first != second
assert verify_password(first, PASSWORD) is True
assert verify_password(second, PASSWORD) is True
def test_salt_is_random_not_derived(self) -> None:
"""A-305 family: the salt is `secrets.token_bytes(16)` — never
a deterministic function of the password (two hashes would
collide otherwise, which is what the differ test pins; this
test pins the salt's independence directly)."""
first = hash_password(PASSWORD)
second = hash_password(PASSWORD)
assert first.split("$")[4] != second.split("$")[4]
class TestSelfDescribing:
"""The params ride the row — a stored string with EXPLICIT
different params still verifies (params evolve without
invalidating rows)."""
def test_row_with_explicit_different_params_verifies(self) -> None:
# An "old-format" row: N=2^10 (cheaper era), same r/p/dklen —
# hand-built in the self-describing format.
n, r, p, dklen = 2**10, 8, 1, 32
salt = b"\x01" * 16
derived = hashlib.scrypt(
PASSWORD.encode("utf-8"),
salt=salt,
n=n,
r=r,
p=p,
dklen=dklen,
maxmem=33554432,
)
stored = "$".join(
(
"scrypt",
str(n),
str(r),
str(p),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(derived).decode("ascii"),
)
)
assert verify_password(stored, PASSWORD) is True
assert verify_password(stored, WRONG) is False
def test_verify_never_uses_module_constants_for_derivation(self) -> None:
"""The pin behind the self-describing guarantee: a row whose
N differs from SCRYPT_N cannot verify if verify (wrongly)
derived at the module N — accept proves it derived at the
row's params."""
n = 2**11 # != SCRYPT_N
salt = b"\x02" * 16
derived = hashlib.scrypt(
PASSWORD.encode("utf-8"),
salt=salt,
n=n,
r=SCRYPT_R,
p=SCRYPT_P,
dklen=SCRYPT_DKLEN,
maxmem=33554432,
)
stored = "$".join(
(
"scrypt",
str(n),
str(SCRYPT_R),
str(SCRYPT_P),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(derived).decode("ascii"),
)
)
assert n != SCRYPT_N
assert verify_password(stored, PASSWORD) is True
class TestMalformedStored:
"""A malformed stored row is an honest False — never a raise (the
auth route maps it to 401, not a 500)."""
def test_empty_stored_rejected(self) -> None:
assert verify_password("", PASSWORD) is False
def test_wrong_algorithm_rejected(self) -> None:
assert verify_password("argon2$1$8$1$c2FsdA$aGFzaA", PASSWORD) is False
def test_truncated_stored_rejected(self) -> None:
assert verify_password("scrypt$16384$8$1", PASSWORD) is False
def test_bad_int_params_rejected(self) -> None:
assert verify_password("scrypt$abc$8$1$c2FsdA$aGFzaA", PASSWORD) is False
def test_bad_base64_rejected(self) -> None:
assert verify_password("scrypt$16384$8$1$!!!$???", PASSWORD) is False
def test_zero_params_rejected(self) -> None:
assert verify_password("scrypt$0$8$1$c2FsdA$aGFzaA", PASSWORD) is False
def test_empty_salt_rejected(self) -> None:
# base64 "" decodes to zero bytes — the _derive guard rejects.
assert verify_password("scrypt$16384$8$1$$aGFzaA", PASSWORD) is False
@@ -0,0 +1,421 @@
"""Own-postings read route tests — GET /v1/employers/postings
(Task 3-1-01, REQ-10-003, D-092/G-319).
Pinned here: own-ENTITY rows only (a second claimed employer's postings
never appear); unclaimed → 403 with the claim-CTA detail dict (the
D-090 authorization class — 401 stays credential failure); status
facets draft|published|closed with per-status counts; per-posting
applicant_count matching seeded applications (the batched
count_applications_by_posting — never a per-posting N+1); no-token
→ 401; the G-319 PII boundary — learner identifiers NEVER ride any
/v1/employers/* response (negative assertion over the full JSON).
REAL scrypt throughout (register mints a real hash — the conftest
fixtures handle limiter resets).
"""
from __future__ import annotations
import json
from typing import Any
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer, Posting
from .conftest import bearer, make_client, register
OWN_ENTITY = "emp-self"
OTHER_ENTITY = "emp-rival"
#: Learner ids that seed applications — the PII the boundary bans.
LEARNER_IDS = ["learner-alpha", "learner-beta", "learner-gamma"]
def seed_posting(
client: TestClient,
posting_id: str,
employer_id: str,
*,
status: str = "draft",
) -> None:
"""Upsert a marketplace posting row through the app's own store
(app.state.marketplace_store — the same DI the route composes),
then drive the lifecycle to `status` via the store's transitions."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
row = store.insert_posting(
Posting(
id=posting_id,
source="seed-corpus",
external_id=posting_id,
employer_id=employer_id,
title=f"Posting {posting_id}",
)
)
if status == "published":
store.set_posting_status(row.id, "published")
elif status == "closed":
store.set_posting_status(row.id, "published")
store.set_posting_status(row.id, "closed")
def seed_application(
client: TestClient, posting_id: str, learner_id: str
) -> None:
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.insert_application(posting_id, learner_id)
def claim_and_approve(
client: TestClient, token: str, employer_id: str
) -> None:
"""File the claim + drive the operator approve (the store path the
2-2 admin route composes) — binds account.employer_id."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
def seed_employer_rows(client: TestClient) -> None:
"""Both entities exist in the marketplace corpus (claim targets the
v0.6 ingested EmployerRow data)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="Self Labs",
website="https://example.com/self",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def get_own_postings(client: TestClient, token: str):
return client.get("/v1/employers/postings", headers=bearer(token))
class TestOwnPostingsRoute:
def test_own_entity_rows_only(self, client: TestClient) -> None:
"""MH-3a's route arm: the response carries ONLY the session
employer's postings — a second claimed employer's rows (all
statuses) never appear."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
# The OWN employer's postings, one per lifecycle status.
seed_posting(client, "own-draft", OWN_ENTITY, status="draft")
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
seed_posting(client, "own-closed", OWN_ENTITY, status="closed")
# A SECOND claimed employer with postings of every status.
rival_token = register(client, email="rival@example.com")["token"]
claim_and_approve(client, rival_token, OTHER_ENTITY)
seed_posting(client, "rival-draft", OTHER_ENTITY, status="draft")
seed_posting(client, "rival-pub", OTHER_ENTITY, status="published")
resp = get_own_postings(client, token)
assert resp.status_code == 200, resp.text
body = resp.json()
ids = [p["id"] for p in body["postings"]]
assert sorted(ids) == ["own-closed", "own-draft", "own-pub"]
assert all(p["employer_id"] == OWN_ENTITY for p in body["postings"])
def test_unclaimed_403_claim_cta(self, client: TestClient) -> None:
"""D-090 403 semantics: a VALID session whose account holds no
claimed entity → 403 + the claim-CTA detail dict (never 401 —
the credential is fine; the authorization is not)."""
token = register(client)["token"]
resp = get_own_postings(client, token)
assert resp.status_code == 403, resp.text
detail = resp.json()["detail"]
assert detail["reason"] == "no_claimed_employer"
assert "claim" in detail["claim_cta"]
def test_no_token_401(self, client: TestClient) -> None:
resp = client.get("/v1/employers/postings")
assert resp.status_code == 401
def test_invalid_token_401(self, client: TestClient) -> None:
resp = get_own_postings(client, "not-a-real-token")
assert resp.status_code == 401
def test_status_facets_pinned(self, client: TestClient) -> None:
"""Per-status facet counts over the OWN postings — all three
lifecycle keys always present, honest zeros included; the rival
employer's rows contribute NOTHING (own-entity scope)."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
seed_posting(client, "own-a", OWN_ENTITY, status="draft")
seed_posting(client, "own-b", OWN_ENTITY, status="draft")
seed_posting(client, "own-c", OWN_ENTITY, status="published")
seed_posting(client, "own-d", OWN_ENTITY, status="closed")
rival_token = register(client, email="rival@example.com")["token"]
claim_and_approve(client, rival_token, OTHER_ENTITY)
seed_posting(client, "rival-a", OTHER_ENTITY, status="published")
resp = get_own_postings(client, token)
assert resp.status_code == 200
body = resp.json()
assert body["facets"] == {"draft": 2, "published": 1, "closed": 1}
assert body["total"] == 4
assert len(body["postings"]) == 4
def test_facets_zero_keys_honest(self, client: TestClient) -> None:
"""Every status key rides the response even at 0 (an employer
with zero postings still gets the full facet shape)."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
resp = get_own_postings(client, token)
assert resp.status_code == 200
body = resp.json()
assert body["postings"] == []
assert body["facets"] == {"draft": 0, "published": 0, "closed": 0}
assert body["total"] == 0
def test_applicant_count_matches_seeded_applications(
self, client: TestClient
) -> None:
"""applicant_count == the seeded application rows per posting
(the D-092 overview data source); zero-application postings
carry the honest 0."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
seed_posting(client, "own-draft", OWN_ENTITY, status="draft")
seed_application(client, "own-pub", LEARNER_IDS[0])
seed_application(client, "own-pub", LEARNER_IDS[1])
seed_application(client, "own-draft", LEARNER_IDS[2])
# A rival posting's applications must not leak into counts.
rival_token = register(client, email="rival@example.com")["token"]
claim_and_approve(client, rival_token, OTHER_ENTITY)
seed_posting(client, "rival-pub", OTHER_ENTITY, status="published")
seed_application(client, "rival-pub", LEARNER_IDS[0])
resp = get_own_postings(client, token)
assert resp.status_code == 200
counts = {p["id"]: p["applicant_count"] for p in resp.json()["postings"]}
assert counts == {"own-pub": 2, "own-draft": 1}
def test_pii_never_rides_the_response(self, client: TestClient) -> None:
"""G-319 PII boundary (binding, negative assertion): the FULL
response JSON contains NO learner identifier — neither the
seeded learner ids nor any per-application datum beyond counts.
Employer-facing responses expose applicant COUNTS ONLY."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
for learner_id in LEARNER_IDS:
seed_application(client, "own-pub", learner_id)
resp = get_own_postings(client, token)
assert resp.status_code == 200
payload: str = resp.text
for learner_id in LEARNER_IDS:
assert learner_id not in payload
# Belt + suspenders: the structured walk finds no per-application
# object anywhere in the tree (only applicant_count integers).
body: dict[str, Any] = resp.json()
assert json.dumps(body).count("applicant_count") == len(
body["postings"]
)
assert all(
isinstance(p["applicant_count"], int) for p in body["postings"]
)
class TestBatchedReadDiscipline:
def test_count_applications_by_posting_single_grouped_read(
self, tmp_path
) -> None:
"""G-319: the counts ride ONE grouped read for the whole id set
(never a per-posting N+1) — pinned at the STORE with an engine
event counter over a many-posting id set."""
from ai_service.marketplace.store import SQLiteMarketplaceStore
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
try:
store.insert_posting(
Posting(
id="p1",
source="seed-corpus",
external_id="p1",
employer_id=OWN_ENTITY,
title="P1",
)
)
store.insert_posting(
Posting(
id="p2",
source="seed-corpus",
external_id="p2",
employer_id=OWN_ENTITY,
title="P2",
)
)
store.insert_application("p1", "learner-a")
store.insert_application("p1", "learner-b")
store.insert_application("p2", "learner-a")
# Count the SELECTs the batched read issues.
selects: list[str] = []
from sqlalchemy import event
@event.listens_for(store._engine, "before_cursor_execute")
def _capture(
conn: object,
cursor: object,
statement: str,
parameters: object,
context: object,
executemany: bool,
) -> None:
if statement.strip().upper().startswith("SELECT"):
selects.append(statement)
result = store.count_applications_by_posting(["p1", "p2", "p-missing"])
assert result == {"p1": 2, "p2": 1}
assert len(selects) == 1 # ONE grouped read, not N per-posting
assert "GROUP BY" in selects[0].upper()
finally:
store.close()
def test_empty_id_set_returns_empty_dict(self, tmp_path) -> None:
from ai_service.marketplace.store import SQLiteMarketplaceStore
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
try:
assert store.count_applications_by_posting([]) == {}
finally:
store.close()
def test_list_postings_by_employer_all_statuses(
self, tmp_path
) -> None:
"""The owner's read sees the FULL lifecycle (draft rows are
visible to their owner, absent from every published-only path),
scoped to the employer_id, posted_at DESC then id."""
from ai_service.marketplace.store import SQLiteMarketplaceStore
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
try:
for pid in ("own-2", "own-1"):
store.insert_posting(
Posting(
id=pid,
source="seed-corpus",
external_id=pid,
employer_id=OWN_ENTITY,
title=pid,
)
)
store.set_posting_status(pid, "published")
store.insert_posting(
Posting(
id="own-draft",
source="seed-corpus",
external_id="own-draft",
employer_id=OWN_ENTITY,
title="own-draft",
)
)
store.insert_posting(
Posting(
id="rival-1",
source="seed-corpus",
external_id="rival-1",
employer_id=OTHER_ENTITY,
title="rival-1",
)
)
store.set_posting_status("rival-1", "published")
rows = store.list_postings_by_employer(OWN_ENTITY)
assert [r.id for r in rows] == ["own-1", "own-2", "own-draft"]
assert {r.status for r in rows} == {"published", "draft"}
finally:
store.close()
class TestRouteDiscipline:
def test_marketplace_store_via_di_never_imported(self) -> None:
"""Territory rule: the route composes the marketplace store via
app.state DI (get_marketplace_store) — api/employers.py never
CONSTRUCTS a store. P4 (Task 4-1-02): the write routes compose
the admin routes' own imports (InvalidTransition, MarketplaceStore
annotations — the marketplace_admin.py:32 precedent), so the pin
asserts the CONSTRUCTION ban, not the import ban."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "SQLiteMarketplaceStore" not in source
assert "get_marketplace_store" in source
def test_route_registered_in_openapi(self, tmp_path) -> None:
"""The read route rides the mounted sibling router (main.py
wiring pin) — /v1/employers/postings exists in the OpenAPI
schema (the _IncludedRouter wrappers hide APIRoutes from
app.routes in this starlette, so the schema is the honest
registry). P4 (Task 4-1-02): the write routes share the path —
POST (create) joined; PATCH/publish/close ride the {id}
subpaths."""
with make_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
path = schema["paths"]["/v1/employers/postings"]
assert set(path) == {"get", "post"}
assert set(
schema["paths"]["/v1/employers/postings/{posting_id}"]
) == {"patch"}
assert set(
schema["paths"][
"/v1/employers/postings/{posting_id}/publish"
]
) == {"post"}
assert set(
schema["paths"][
"/v1/employers/postings/{posting_id}/close"
]
) == {"post"}
def test_own_postings_contract_shapes(self) -> None:
"""G-319: OwnPostingSummary REUSES the PostingSummary wire shape
(every marketplace.base.Posting field present, unchanged) +
applicant_count; the response contract carries the list +
facets/total."""
from ai_service.employers.base import (
OwnPostingsFacets,
OwnPostingsResponse,
OwnPostingSummary,
)
from ai_service.marketplace.base import Posting
posting_fields = set(Posting.model_fields)
assert posting_fields < set(OwnPostingSummary.model_fields)
assert set(OwnPostingSummary.model_fields) - posting_fields == {
"applicant_count"
}
assert set(OwnPostingsFacets.model_fields) == {
"draft",
"published",
"closed",
}
assert set(OwnPostingsResponse.model_fields) == {
"postings",
"facets",
"total",
}
@@ -0,0 +1,445 @@
"""Own-postings write routes (Task 4-1-02, REQ-10-004, G-311/317/318).
Pins the employer-principal posting lifecycle: create consumes from
the employer's OWN package through the SHARED entitlement gate (the
D-091 helper — the no-drift proof rides the admin/employer payload
parity asserted in tests/billing/test_entitlement_parity.py); the
G-311 gate ORDER (session 401 → claim 403 → entitlement 402 →
insert); the G-317 employer provenance stamp; the G-318 own-drafts
edit guard with the identical admin re-tag composition.
Fixtures: the conftest make_client/register/bearer + the
test_postings_read.py claim_and_approve/seed precedents; the billing
grant via the test_entitlement_parity.py grant_posts shape.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.billing.store import SQLiteBillingStore
from ai_service.marketplace.base import Employer
from ..billing.test_entitlement_parity import grant_posts
from .conftest import bearer, make_client, register
OWN_ENTITY = "emp-write-self"
OTHER_ENTITY = "emp-write-rival"
EMPLOYER_CTA_NO_PACKAGE = (
"This employer has no active posting package — "
"purchase one via /v1/employers/billing/checkout."
)
EMPLOYER_CTA_EXHAUSTED = (
"This employer's posting package is exhausted — "
"buy more posts or upgrade via /v1/employers/billing/checkout."
)
def seed_employer_rows(client: TestClient) -> None:
"""Both entities exist in the marketplace corpus (claim targets
the v0.6 ingested EmployerRow data)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="Write Labs",
website="https://example.com/write",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def claim_and_approve(
client: TestClient, token: str, employer_id: str
) -> None:
"""File the claim + drive the operator approve (the store path
the 2-2 admin route composes) — binds account.employer_id."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
def create_own_posting(
client: TestClient, token: str, title: str = "Owned Role"
):
return client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": title, "description": "g", "skills": ["python"]},
)
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
@pytest.fixture()
def claimed(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[tuple[TestClient, str]]:
"""One registered + claimed-and-approved employer session with the
billing store state-injected (G-9). The with-block runs the
lifespan so app.state stores wire before any seeding."""
with make_client(tmp_path) as client:
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
yield client, token
def _raw_posts_used(store: SQLiteBillingStore, employer_id: str) -> int:
"""The raw row read carries the store truth an exhausted purchase
hides from get_entitlement (the parity-test precedent)."""
from sqlalchemy import text
from sqlmodel import Session
with Session(store._engine) as session:
used = session.execute(
text(
"SELECT posts_used FROM billing_purchases "
"WHERE employer_id = :eid ORDER BY created_at DESC"
),
{"eid": employer_id},
).scalar()
assert used is not None # the row exists (granted in-test)
return int(used)
class TestCreateOwnPosting:
def test_create_consumes_own_package(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""The employer's OWN package is charged (posts_used 0 → 1 via
the raw row read) and the response carries the G-317 stamp."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 2)
resp = create_own_posting(client, token)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["source"] == "employer"
assert body["id"].startswith("emp-")
assert body["external_id"].startswith("emp-")
assert body["employer_id"] == OWN_ENTITY
assert body["status"] == "draft"
assert _raw_posts_used(billing_store, OWN_ENTITY) == 1
def test_no_package_402_exact_dict(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""No package → the canonical 402 dict with the EMPLOYER CTA
(the D-091 payload parity, employer arm, through the route)."""
client, token = claimed
resp = create_own_posting(client, token)
assert resp.status_code == 402
assert resp.json()["detail"] == {
"reason": "no_active_package",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": EMPLOYER_CTA_NO_PACKAGE,
}
def test_exhausted_402_exact_dict(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""All posts spent → the exhausted arm with the EMPLOYER CTA."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
assert create_own_posting(client, token).status_code == 201
denied = create_own_posting(client, token, title="Second Role")
assert denied.status_code == 402
assert denied.json()["detail"] == {
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": EMPLOYER_CTA_EXHAUSTED,
}
def test_402_consumes_nothing_creates_nothing(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""R-58 gate-before-insert: the denied create leaves
posts_used unchanged and inserts no marketplace row."""
client, token = claimed
marketplace_store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
grant_posts(billing_store, OWN_ENTITY, 1)
assert create_own_posting(client, token).status_code == 201
before = marketplace_store.list_postings_by_employer(OWN_ENTITY)
assert len(before) == 1
denied = create_own_posting(client, token, title="Denied Role")
assert denied.status_code == 402
assert _raw_posts_used(billing_store, OWN_ENTITY) == 1
after = marketplace_store.list_postings_by_employer(OWN_ENTITY)
assert len(after) == 1
assert [p.id for p in after] == [p.id for p in before]
def test_unlimited_grant_bypasses_402(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""An unlimited (enterprise) manual grant never 402s (the
admin-route bypass precedent, employer arm)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, None)
for i in range(3):
assert (
create_own_posting(client, token, title=f"Role {i}").status_code
== 201
)
def test_no_token_401_never_402(self, tmp_path: Path) -> None:
"""G-311 order: unauthenticated → 401, never a 402 (the
entitlement state never leaks to anonymous callers)."""
with make_client(tmp_path) as client:
resp = client.post(
"/v1/employers/postings", json={"title": "X"}
)
assert resp.status_code == 401
def test_unclaimed_403_never_402(self, tmp_path: Path) -> None:
"""G-311 order: unclaimed session → 403 + claim-CTA, never a
402 (a logged-out visitor must not see an upgrade CTA)."""
with make_client(tmp_path) as client:
token = register(client)["token"]
resp = client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": "X"},
)
assert resp.status_code == 403
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
class TestEditOwnPosting:
def test_edit_retags_on_mock_provider(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""G-318: the edit composes the IDENTICAL update_posting_fields
+ tag_posting re-tag path as the admin edit (D-052 — derived
tags). The plain MockProvider's canned reply does not validate
as PostingTags → the re-tag fails honestly (the admin suite's
test_edit_retags_and_old_tags_gone contract): the edit LANDS,
pre-existing tags are KEPT, never fabricated — and the G-317
stamp survives the edit."""
from ai_service.marketplace.base import PostingTags
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.set_posting_tags(
created["id"],
PostingTags(skills=["stale-skill"], seniority=None, category=None),
)
resp = client.patch(
f"/v1/employers/postings/{created['id']}",
headers=bearer(token),
json={"title": "Edited Title", "skills": ["typescript"]},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["title"] == "Edited Title"
assert body["source"] == "employer"
stored = store.get_posting(created["id"])
assert stored is not None
assert stored.title == "Edited Title"
assert stored.skills == ["typescript"]
# Honest tag failure: the edit stands, the OLD tags remain
# (never erased by a bad parse, never fabricated).
assert stored.tags is not None
assert stored.tags.skills == ["stale-skill"]
def test_edit_published_409_no_side_effects(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""G-318: the own-drafts guard evaluates BEFORE any mutation —
editing a PUBLISHED posting 409s with fields AND tags
untouched."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
assert (
client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
).status_code
== 200
)
resp = client.patch(
f"/v1/employers/postings/{created['id']}",
headers=bearer(token),
json={"title": "Must Not Land"},
)
assert resp.status_code == 409
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
stored = store.get_posting(created["id"])
assert stored is not None
assert stored.title == "Owned Role" # unchanged
assert stored.status == "published"
def test_edit_cross_entity_403(
self, claimed: tuple[TestClient, str]
) -> None:
"""D-090 authorization arm: another entity's posting → 403."""
client, token = claimed
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
rival_row = store.insert_posting(
__import__(
"ai_service.marketplace.base", fromlist=["Posting"]
).Posting(
id="rival-posting-1",
source="seed-corpus",
external_id="rival-posting-1",
employer_id=OTHER_ENTITY,
title="Rival Role",
)
)
resp = client.patch(
f"/v1/employers/postings/{rival_row.id}",
headers=bearer(token),
json={"title": "Hostile Takeover"},
)
assert resp.status_code == 403
assert store.get_posting(rival_row.id).title == "Rival Role"
def test_edit_unknown_404(
self, claimed: tuple[TestClient, str]
) -> None:
client, token = claimed
resp = client.patch(
"/v1/employers/postings/does-not-exist",
headers=bearer(token),
json={"title": "X"},
)
assert resp.status_code == 404
class TestPublishCloseLifecycle:
def test_publish_close_applicants_accrue(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""draft → publish (posted_at stamped, hooks composed) → an
application lands → close; the read route's applicant_count
reflects the application (the P3 batched read over the
lifecycle)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
published = client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
)
assert published.status_code == 200, published.text
assert published.json()["status"] == "published"
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
stored = store.get_posting(created["id"])
assert stored is not None and stored.posted_at is not None
store.insert_application(created["id"], "learner-alpha")
listing = client.get(
"/v1/employers/postings", headers=bearer(token)
)
assert listing.status_code == 200
own = listing.json()["postings"]
assert [p for p in own if p["id"] == created["id"]][0][
"applicant_count"
] == 1
closed = client.post(
f"/v1/employers/postings/{created['id']}/close",
headers=bearer(token),
)
assert closed.status_code == 200
assert closed.json()["status"] == "closed"
def test_publish_published_409(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""Re-publishing a published posting → 409 (the store's
transition matrix, honestly surfaced)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
first = client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
)
assert first.status_code == 200
second = client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
)
assert second.status_code == 409
def test_close_draft_409(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""Closing a DRAFT → 409 (published → closed is the only valid
move)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
resp = client.post(
f"/v1/employers/postings/{created['id']}/close",
headers=bearer(token),
)
assert resp.status_code == 409
def test_publish_cross_entity_403(
self, claimed: tuple[TestClient, str]
) -> None:
"""The own-entity guard on publish/close too (D-090)."""
client, token = claimed
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
rival_row = store.insert_posting(
__import__(
"ai_service.marketplace.base", fromlist=["Posting"]
).Posting(
id="rival-posting-2",
source="seed-corpus",
external_id="rival-posting-2",
employer_id=OTHER_ENTITY,
title="Rival Draft",
)
)
resp = client.post(
f"/v1/employers/postings/{rival_row.id}/publish",
headers=bearer(token),
)
assert resp.status_code == 403
@@ -0,0 +1,390 @@
"""EmployerStore tests — the 8th D-027 store (Task 1-1-02, REQ-10-001).
Mirrors tests/billing/test_store.py: tmp_path db, sqlite_master
table-existence pins (all four employer_ tables), PRAGMA pins,
IntegrityError surfacing (duplicate email, duplicate token_hash),
the D-087 hashed-token discipline (row holds sha256 ONLY — never the
raw token), the G-306 lazy TTL with injected now (+6d present, +8d
absent — no sleeps), and revoke → lookup miss.
"""
from __future__ import annotations
import hashlib
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session
from ai_service.employers.store import (
SESSION_TTL_DAYS,
SQLiteEmployerStore,
)
EMAIL = "user@example.com"
PASSWORD_HASH = "scrypt$16384$8$1$c2FsdA$aGFzaA" # any self-describing string
def make_store(tmp_path: Path) -> SQLiteEmployerStore:
return SQLiteEmployerStore(db_path=tmp_path / "employers-test.db")
def insert_account(
store: SQLiteEmployerStore, email: str = EMAIL
) -> tuple[str, bool]:
row, created = store.insert_account(email=email, password_hash=PASSWORD_HASH)
return row.id, created
class TestSchema:
def test_all_four_employer_tables_exist(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
with store._engine.connect() as conn:
names = {
row[0]
for row in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
}
for table in (
"employer_accounts",
"employer_sessions",
"employer_claims",
"employer_audit_events",
):
assert table in names
def test_module_registers_only_employer_prefixed_tables(self) -> None:
"""The bare `employer` name belongs to marketplace's EmployerRow
(marketplace/store.py:144) — every table THIS module registers
carries the employer_ prefix (R-57); the shared-process metadata
holds marketplace's rows, but the module owns exactly four."""
from ai_service.employers import store as employers_store_module
module_tables = {
employers_store_module.AccountRow.__tablename__,
employers_store_module.SessionRow.__tablename__,
employers_store_module.ClaimRow.__tablename__,
employers_store_module.AuditEventRow.__tablename__,
}
assert module_tables == {
"employer_accounts",
"employer_sessions",
"employer_claims",
"employer_audit_events",
}
assert all(t.startswith("employer_") for t in module_tables)
def test_pragmas(self, tmp_path: Path) -> None:
"""WAL + synchronous=NORMAL + busy_timeout + foreign_keys —
the D-027 per-connection contract."""
store = make_store(tmp_path)
with store._engine.connect() as conn:
journal_row = conn.execute(text("PRAGMA journal_mode")).fetchone()
sync_row = conn.execute(text("PRAGMA synchronous")).fetchone()
busy_row = conn.execute(text("PRAGMA busy_timeout")).fetchone()
fk_row = conn.execute(text("PRAGMA foreign_keys")).fetchone()
assert journal_row is not None and sync_row is not None
assert busy_row is not None and fk_row is not None
assert str(journal_row[0]).lower() == "wal"
assert sync_row[0] == 1 # NORMAL
assert busy_row[0] == 5000
assert fk_row[0] == 1
def test_session_ttl_days_pinned(self) -> None:
"""SESSION_TTL_DAYS = 7 — module constant (D-087), pinned by
test (the PAST_DUE_GRACE_DAYS shape)."""
assert SESSION_TTL_DAYS == 7
def test_create_schema_idempotent(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
store.create_schema() # second run — shared-DB re-boot shape
account_id, created = insert_account(store)
assert created is True
with Session(store._engine) as session:
count = int(
session.execute(
text("SELECT COUNT(*) FROM employer_accounts")
).scalar_one()
)
assert count == 1
class TestAccounts:
def test_insert_account_created_true(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
_, created = insert_account(store)
assert created is True
def test_duplicate_email_rides_existing_row_created_false(
self, tmp_path: Path
) -> None:
"""Check-first: the EXISTING row rides the duplicate register —
no second row, honest created=False (the route's 409)."""
store = make_store(tmp_path)
first_id, created_first = insert_account(store, email=EMAIL)
second_row, created_second = store.insert_account(
email=EMAIL, password_hash="scrypt$other"
)
assert created_first is True
assert created_second is False
assert second_row.id == first_id
assert second_row.password_hash == PASSWORD_HASH # original kept
def test_get_account_by_email(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id, _ = insert_account(store)
row = store.get_account_by_email(EMAIL)
assert row is not None
assert row.id == account_id
assert row.employer_id is None # nullable-until-claim-approval
def test_get_account_by_email_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert store.get_account_by_email("nobody@example.com") is None
def test_email_normalized_at_store_boundary(self, tmp_path: Path) -> None:
"""G-315: the store re-derives normalization — never trusts
the caller's casing/whitespace."""
store = make_store(tmp_path)
_, created = store.insert_account(
email=" User@Example.COM ", password_hash=PASSWORD_HASH
)
assert created is True
row = store.get_account_by_email("user@example.com")
assert row is not None
assert row.email == "user@example.com"
def test_dishonest_email_rejected_at_store_boundary(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
with pytest.raises(ValueError, match="email"):
store.insert_account(email="no-at-sign", password_hash=PASSWORD_HASH)
def test_duplicate_email_integrity_error_surfaced(
self, tmp_path: Path
) -> None:
"""The UNIQUE(email) constraint is the check-first race
backstop — its IntegrityError SURFACES (never swallowed, never
re-masked). Raw SQL via a direct Session simulates the race
window after the check-first read."""
store = make_store(tmp_path)
insert_account(store)
with Session(store._engine) as session:
with pytest.raises(IntegrityError):
session.execute(
text(
"INSERT INTO employer_accounts (id, email, password_hash, "
"created_at) VALUES ('acct-raced', :email, :ph, :now)"
),
{
"email": EMAIL,
"ph": PASSWORD_HASH,
"now": datetime.now(UTC),
},
)
session.flush()
session.rollback()
def test_duplicate_employer_id_integrity_error_surfaced(
self, tmp_path: Path
) -> None:
"""The one-account-one-entity constraint (UNIQUE nullable
employer_id) — two accounts on one entity surface."""
store = make_store(tmp_path)
insert_account(store, email="one@example.com")
insert_account(store, email="two@example.com")
with Session(store._engine) as session:
session.execute(
text(
"UPDATE employer_accounts SET employer_id = 'emp-1' "
"WHERE email = :email"
),
{"email": "one@example.com"},
)
session.flush() # materialize the first UPDATE before racing
with pytest.raises(IntegrityError):
session.execute(
text(
"UPDATE employer_accounts SET employer_id = 'emp-1' "
"WHERE email = :email"
),
{"email": "two@example.com"},
)
session.flush()
session.rollback()
class TestSessions:
def test_mint_session_hashes_token_sha256(self, tmp_path: Path) -> None:
"""D-087: the row holds sha256(token) ONLY — never the raw
token, in any column, ever."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
token = "raw-secret-bearer-token"
row = store.mint_session(account_id=account_id, token=token)
expected = hashlib.sha256(token.encode("utf-8")).hexdigest()
assert row.token_hash == expected
assert row.token_hash != token
with Session(store._engine) as session:
raw = session.execute(
text("SELECT token_hash FROM employer_sessions")
).scalar_one()
assert raw == expected
assert token not in str(raw) # raw token nowhere in the table
def test_get_session_by_token_hash_roundtrip(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id, _ = insert_account(store)
token = "raw-secret-bearer-token"
minted = store.mint_session(account_id=account_id, token=token)
found = store.get_session_by_token_hash(minted.token_hash)
assert found is not None
assert found.id == minted.id
assert found.account_id == account_id
def test_expires_at_is_login_plus_seven_days(self, tmp_path: Path) -> None:
"""The TTL constant binds: expires_at = minted now + 7d
(SESSION_TTL_DAYS — pinned in TestSchema too)."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
moment = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
row = store.mint_session(account_id=account_id, token="t", now=moment)
assert row.expires_at is not None
assert row.expires_at == moment + timedelta(days=SESSION_TTL_DAYS)
def test_lazy_ttl_inside_window_present(self, tmp_path: Path) -> None:
"""G-306 with injected now: login+6d the session still reads —
no sleeps, no timers."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
row = store.mint_session(
account_id=account_id, token="t", now=login_at
)
six_days_later = login_at + timedelta(days=6)
assert store.get_session_by_token_hash(
row.token_hash, now=six_days_later
) is not None
def test_lazy_ttl_beyond_window_absent(self, tmp_path: Path) -> None:
"""G-306 with injected now: login+8d the session reads as
ABSENT (expired) — evaluated lazily, the row never deleted."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
row = store.mint_session(
account_id=account_id, token="t", now=login_at
)
eight_days_later = login_at + timedelta(days=8)
assert (
store.get_session_by_token_hash(
row.token_hash, now=eight_days_later
)
is None
)
# Lazy: the row is still on disk (no timer, no writes on read).
with Session(store._engine) as session:
count = int(
session.execute(
text("SELECT COUNT(*) FROM employer_sessions")
).scalar_one()
)
assert count == 1
def test_duplicate_token_hash_integrity_error_surfaced(
self, tmp_path: Path
) -> None:
"""UNIQUE(token_hash): minting the same raw token twice
surfaces IntegrityError — never a second row."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
store.mint_session(account_id=account_id, token="same-token")
with pytest.raises(IntegrityError):
store.mint_session(account_id=account_id, token="same-token")
def test_revoke_session_deletes_row(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id, _ = insert_account(store)
row = store.mint_session(account_id=account_id, token="t")
assert store.revoke_session(row.token_hash) is True
# Lookup miss after revoke (logout semantics).
assert store.get_session_by_token_hash(row.token_hash) is None
# Revoking again is an honest miss, not an error.
assert store.revoke_session(row.token_hash) is False
def test_revoke_unknown_token_hash_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert store.revoke_session("deadbeef" * 8) is False
def test_concurrent_sessions_per_account_allowed(
self, tmp_path: Path
) -> None:
"""Two logins → two live sessions (the logout-one-leaves-the-
other-alive route test's store half)."""
store = make_store(tmp_path)
account_id, _ = insert_account(store)
first = store.mint_session(account_id=account_id, token="token-one")
second = store.mint_session(account_id=account_id, token="token-two")
assert store.get_session_by_token_hash(first.token_hash) is not None
assert store.get_session_by_token_hash(second.token_hash) is not None
class TestAuditEvents:
def test_append_and_read_back(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id, _ = insert_account(store)
row = store.append_audit_event(
"auth",
"register",
account_id=account_id,
detail={"email": EMAIL},
)
assert row.id is not None
assert row.detail == {"email": EMAIL}
with Session(store._engine) as session:
stored = session.execute(
text(
"SELECT kind, name, account_id FROM employer_audit_events"
)
).fetchone()
assert stored is not None
assert tuple(stored) == ("auth", "register", account_id)
def test_operator_provenance_round_trips(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
store.append_audit_event(
"manual",
"grant",
employer_id="emp-1",
operator="op-1",
)
with Session(store._engine) as session:
stored = session.execute(
text(
"SELECT employer_id, operator FROM employer_audit_events"
)
).fetchone()
assert stored is not None
assert tuple(stored) == ("emp-1", "op-1")
def test_invalid_kind_rejected(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
with pytest.raises(ValueError, match="invalid audit event kind"):
store.append_audit_event("bogus", "x")
def test_empty_name_rejected(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
with pytest.raises(ValueError, match="name"):
store.append_audit_event("auth", "")
class TestStoreContract:
def test_close_is_idempotent_safe(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
store.close()
store.close() # engine.dispose() is idempotent by contract
@@ -0,0 +1,218 @@
"""Employer auth throttle tests (Task 1-2-02, D-088/G-313/G-314/G-320a).
Knob-math pins, no sleeps: every window boundary is exercised via the
Settings knobs set low, never by waiting. The G-313 conftest fixture
resets both limiter module states around every test.
G-314 stuffing test: N distinct emails inside the window → the
(cap+1)-th attempt 429s — failed-password attempts DO count; only
422-invalid bodies and the cap's own 429s are exempt.
G-320a pin: a 429'd login performs ZERO scrypt derivations (a
hash-spy counts _derive calls — a capped attempt must never burn
KDF CPU, bounding open-risk-1).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi.testclient import TestClient
from ai_service.api import employers as employers_api
from ai_service.employers import passwords
from .conftest import TEST_PASSWORD, make_client, register
def make_throttle_client(
tmp_path: Path,
*,
per_min: int | None = None,
failures_per_window: int | None = None,
) -> TestClient:
overrides: dict[str, Any] = {}
if per_min is not None:
overrides["employer_auth_attempts_per_min"] = per_min
if failures_per_window is not None:
overrides["employer_login_failures_per_window"] = failures_per_window
return make_client(tmp_path, **overrides)
def failed_login(client: TestClient, email: str) -> int:
return client.post(
"/v1/employers/auth/login",
json={"email": email, "password": "wrong-password-123"},
).status_code
def good_login(client: TestClient, email: str) -> int:
return client.post(
"/v1/employers/auth/login",
json={"email": email, "password": TEST_PASSWORD},
).status_code
class TestPerEmailFailedLoginWindow:
def test_five_failures_then_429_with_wait_detail(self, tmp_path: Path) -> None:
"""Default knob 5: failures 1-5 → 401, the 6th → 429 carrying
the honest wait detail (a further 401 would invite retries)."""
with make_throttle_client(tmp_path) as client:
register(client, email="victim@example.com")
for _ in range(5):
assert failed_login(client, "victim@example.com") == 401
resp = client.post(
"/v1/employers/auth/login",
json={
"email": "victim@example.com",
"password": "wrong-password-123",
},
)
assert resp.status_code == 429
assert "wait" in resp.json()["detail"].lower()
def test_failures_are_per_email(self, tmp_path: Path) -> None:
"""One email's window never locks another's (D-088: keyed by
submitted email — never global at this layer)."""
with make_throttle_client(tmp_path, failures_per_window=2) as client:
register(client, email="a@example.com")
register(client, email="b@example.com")
assert failed_login(client, "a@example.com") == 401
assert failed_login(client, "a@example.com") == 401
assert failed_login(client, "a@example.com") == 429
assert failed_login(client, "b@example.com") == 401 # untouched
def test_success_clears_email_window(self, tmp_path: Path) -> None:
"""D-088: a successful login CLEARS the email's failure window
— 4 failures, a good login, then 4 more failures all pass."""
with make_throttle_client(tmp_path, failures_per_window=5) as client:
register(client, email="healthy@example.com")
for _ in range(4):
assert failed_login(client, "healthy@example.com") == 401
assert good_login(client, "healthy@example.com") == 200
for _ in range(4):
assert failed_login(client, "healthy@example.com") == 401
def test_unknown_email_failures_also_windowed(self, tmp_path: Path) -> None:
"""Failures against an email with NO account still append —
the attacker probing dead addresses is bounded identically."""
with make_throttle_client(tmp_path, failures_per_window=3) as client:
for _ in range(3):
assert failed_login(client, "ghost@example.com") == 401
assert failed_login(client, "ghost@example.com") == 429
def test_window_state_is_the_module_dict(self, tmp_path: Path) -> None:
"""R-63 shape pin: the window lives in the module dict keyed
by the submitted email (monotonic timestamps, failures-only)."""
with make_throttle_client(tmp_path, failures_per_window=2) as client:
register(client, email="shape@example.com")
failed_login(client, "shape@example.com")
failed_login(client, "shape@example.com")
window = employers_api._LOGIN_FAILURES["shape@example.com"]
assert len(window) == 2
assert all(isinstance(t, float) for t in window)
class TestGlobalAttemptCap:
def test_cap_reached_429(self, tmp_path: Path) -> None:
"""Knob 3: three well-formed attempts admitted, the 4th → 429
with the honest wait detail."""
with make_throttle_client(tmp_path, per_min=3) as client:
register(client, email="one@example.com")
register(client, email="two@example.com")
good_login(client, "one@example.com")
resp = client.post(
"/v1/employers/auth/login",
json={"email": "one@example.com", "password": TEST_PASSWORD},
)
assert resp.status_code == 429
assert "wait" in resp.json()["detail"].lower()
def test_422_invalid_consumes_nothing(self, tmp_path: Path) -> None:
"""G-314 exemption: a 422-invalid body never reaches the
cap's append — after N invalid attempts the budget is whole
(the cap only counts well-formed bodies; pydantic rejects
these BEFORE the handler runs)."""
with make_throttle_client(tmp_path, per_min=2) as client:
for _ in range(5):
resp = client.post(
"/v1/employers/auth/login",
json={"email": "x@example.com", "password": "short"},
)
assert resp.status_code == 422
assert len(employers_api._AUTH_ATTEMPTS) == 0
register(client, email="one@example.com")
register(client, email="two@example.com")
# Budget was 2, both consumed by the two registers — the
# next WELL-FORMED attempt 429s (the cap is intact: the
# five 422s above consumed nothing).
assert good_login(client, "one@example.com") == 429
def test_failed_passwords_count_stuffing_test(self, tmp_path: Path) -> None:
"""G-314: N distinct emails inside the window, each a FAILED
password → the (cap+1)-th 429s — failed attempts DO consume
the global budget or D-088's credential-stuffing bound is
void (rotating emails must not evade the cap)."""
with make_throttle_client(tmp_path, per_min=4) as client:
for i in range(4):
assert failed_login(client, f"stuff{i}@example.com") == 401
assert failed_login(client, "stuff4@example.com") == 429
def test_cap_429_consumes_nothing(self, tmp_path: Path) -> None:
"""The cap's own 429 never appends (a rejected attempt
consumes no budget — the _check_global_create_rate shape)."""
with make_throttle_client(tmp_path, per_min=2) as client:
register(client, email="one@example.com")
register(client, email="two@example.com")
assert failed_login(client, "three@example.com") == 429
assert len(employers_api._AUTH_ATTEMPTS) == 2
class TestThrottleBeforeKDF:
def test_429_performs_zero_kdf_derivations(self, tmp_path: Path) -> None:
"""G-320a pin: a login 429'd by the per-email window performs
ZERO scrypt derivations — both throttle checks run BEFORE the
verify, bounding the open-risk-1 burst (a hash-spy on
passwords._derive counts calls)."""
calls: list[int] = []
real_derive = passwords._derive
def spy(password: str, **kwargs: object) -> bytes:
calls.append(1)
return real_derive(password, **kwargs) # type: ignore[arg-type]
passwords._derive = spy
try:
with make_throttle_client(tmp_path, failures_per_window=2) as client:
register(client, email="kdf@example.com")
calls.clear() # the register hash is not under test
assert failed_login(client, "kdf@example.com") == 401
assert failed_login(client, "kdf@example.com") == 401
assert failed_login(client, "kdf@example.com") == 429
# The two 401s each performed ONE verify derivation; the
# 429'd attempt performed ZERO — total stays at 2.
assert sum(calls) == 2
finally:
passwords._derive = real_derive
def test_global_cap_429_performs_zero_kdf_derivations(
self, tmp_path: Path
) -> None:
"""G-320a pin, global arm: a cap-429'd attempt burns no KDF."""
calls: list[int] = []
real_derive = passwords._derive
def spy(password: str, **kwargs: object) -> bytes:
calls.append(1)
return real_derive(password, **kwargs) # type: ignore[arg-type]
passwords._derive = spy
try:
with make_throttle_client(tmp_path, per_min=1) as client:
register(client, email="one@example.com")
calls.clear() # the register hash is not under test
assert failed_login(client, "two@example.com") == 429
assert sum(calls) == 0 # the capped attempt derived nothing
finally:
passwords._derive = real_derive
@@ -29,6 +29,25 @@ MARKETPLACE_LEARNERS = ["pilot-learner", "pilot-learner-2"]
ADMIN_TOKEN = "test-admin-token"
#: v0.9 P4 (risk-5): every employer the pre-v0.9 marketplace tests
#: create postings against — granted unlimited in make_client so the
#: enforcement composition (REQ-9-004) preserves legacy semantics.
_LEGACY_TEST_EMPLOYERS = (
"emp-openai",
"emp-langchain",
"emp-x",
"emp-anduril",
"emp-anthropic",
"emp-cohere",
"emp-deepmind",
"emp-huggingface",
"emp-mistral",
"emp-perplexity",
"emp-pinecone",
"emp-recursion",
"emp-replicate",
)
@pytest.fixture()
def marketplace_store(tmp_path: Path) -> Iterator[SQLiteMarketplaceStore]:
@@ -77,6 +96,27 @@ def make_client(
app = create_app(settings)
app.state.identity_store = identity_store # state-injection (G-9)
app.state.marketplace_store = marketplace_store
# v0.9 P4 (risk-5 fixture strategy): posting creation now enforces
# billing entitlements (REQ-9-004). Legacy marketplace tests create
# postings for seeded employers with zero purchases — this fixture
# grants each a manual UNLIMITED purchase (the D-080 enterprise
# path), explicitly, so the pre-v0.9 test semantics stay intact.
from ai_service.billing.store import SQLiteBillingStore
billing_store = SQLiteBillingStore(db_path=tmp_path / "billing-grants.db")
for employer_id in _LEGACY_TEST_EMPLOYERS:
session_row = billing_store.insert_checkout_session(
provider_session_id=f"cs_legacy_{employer_id}",
employer_id=employer_id,
item_id="pkg-enterprise",
item_name="Enterprise (test grant)",
price_cents=0,
mode="payment",
)
billing_store.create_purchase_from_session(
session_row, posts_limit=None, duration_days=None
)
app.state.billing_store = billing_store
return TestClient(app)
@@ -0,0 +1,116 @@
"""Placement test fixtures (hermeticity rule: db_path pins tmp_path —
never ~/.nextcraft, never the cloud).
Mirrors the tests/marketplace pattern: tmp-path stores injected into
a real create_app via state-injection (the G-9 precedent) — the
transition routes see the exact composition production runs.
G-313 (D-111): the autouse `_APPLY_TIMES` reset — the learner
withdraw route (P2) rides the marketplace apply-rate window; without
this reset the suite self-429s (the lesson applied BEFORE it ships).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.config import Settings
from ai_service.identity.store import SQLiteIdentityStore
from ai_service.main import create_app
from ai_service.marketplace.store import SQLiteMarketplaceStore
from ai_service.placements.store import SQLitePlacementStore
from ..conftest import SUITE_LEARNERS, seed_verified_identity
#: The pilot learner roster for gated-route tests (G-5 allowlist
#: coverage + verified 18+ identity records).
PLACEMENT_LEARNERS = ["pilot-learner", "pilot-learner-2"]
ADMIN_TOKEN = "test-admin-token"
@pytest.fixture()
def marketplace_store(tmp_path: Path) -> Iterator[SQLiteMarketplaceStore]:
"""Hermetic store: D-100's topology — marketplace + placements
share ONE SQLite file (the cross-store transaction depends on
it)."""
store = SQLiteMarketplaceStore(db_path=tmp_path / "shared.db")
yield store
store.close()
@pytest.fixture()
def identity_store(tmp_path: Path) -> Iterator[SQLiteIdentityStore]:
store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
seed_verified_identity(store, PLACEMENT_LEARNERS)
yield store
store.close()
@pytest.fixture()
def placement_store(tmp_path: Path) -> Iterator[SQLitePlacementStore]:
"""The 9th D-027 store on the SAME shared file as the marketplace
store (D-100/R-72: one SQLite file, two engines — the shipped
multi-store topology)."""
store = SQLitePlacementStore(db_path=tmp_path / "shared.db")
yield store
store.close()
def make_client(
tmp_path: Path,
identity_store: SQLiteIdentityStore,
marketplace_store: SQLiteMarketplaceStore,
admin_token: str | None = ADMIN_TOKEN,
) -> TestClient:
"""Full app with state-injected stores; admin_token=None exercises
the D-057 fail-closed matrix. P1-2 (security review): ALL stores
share the ONE tmp file (marketplace rides shared.db; the app DB +
placement store point there too) — D-100's cross-store transition
depends on the single-file topology, exactly as production runs."""
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "shared.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=SecretStr(admin_token or ""),
)
app = create_app(settings)
app.state.identity_store = identity_store # state-injection (G-9)
app.state.marketplace_store = marketplace_store
app.state.placement_store = SQLitePlacementStore(db_path=tmp_path / "shared.db")
return TestClient(app)
@pytest.fixture(autouse=True)
def _reset_apply_rate_window() -> Iterator[None]:
"""The apply rate cap is module-level process state (the D-019
precedent); tests must not inherit each other's windows — the P2
withdraw route rides this limiter (G-313 applied BEFORE it
ships)."""
from ai_service.api.marketplace import _APPLY_TIMES
_APPLY_TIMES.clear()
yield
_APPLY_TIMES.clear()
@pytest.fixture()
def client(
tmp_path: Path,
identity_store: SQLiteIdentityStore,
marketplace_store: SQLiteMarketplaceStore,
) -> Iterator[TestClient]:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
yield c
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
@@ -0,0 +1,249 @@
"""The operator lane tests (D-106/G-326/G-327, REQ-11-002) — the
fail-closed matrix, the generic transition arms, revoke, and the
R-80 re-hire route."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import ADMIN_TOKEN, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
import uuid
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
# the learner apply route requires published (409 on drafts)
store.set_posting_status(posting.id, "published")
return posting.id
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
resp = client.post(
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
)
assert resp.status_code == 200, resp.text
return resp.json()["application_id"]
def _admin(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
application_id = _apply(entered, posting_id)
yield {
"client": entered,
"posting_id": posting_id,
"application_id": application_id,
}
# -- the fail-closed matrix (the R-56 shape) -------------------------------------
def test_fail_closed_no_token(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "in_review"},
)
assert resp.status_code == 403
def test_fail_closed_bad_token(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "in_review"},
headers=_admin("wrong-token"),
)
assert resp.status_code == 403
def test_fail_closed_revoke_and_rehire(scaffold) -> None:
client = scaffold["client"]
# no token → 403 on every route (fail-closed)
resp = client.post(
"/v1/placements/admin/placements/plc-x/revoke",
)
assert resp.status_code == 403
# invalid token → 403 (never a 404/409 — the gate runs first)
resp = client.post(
"/v1/placements/admin/placements/plc-x/revoke",
headers=_admin("wrong-token"),
)
assert resp.status_code == 403
resp = client.post(
f"/v1/placements/admin/placements/by-application/{scaffold['application_id']}/re-hire",
json={"role_title": "Engineer", "start_date": "2026-12-01"},
headers=_admin("wrong-token"),
)
assert resp.status_code == 403
# -- the generic transition route --------------------------------------------------
def test_admin_transition_every_legal_arm(scaffold) -> None:
client, app_id = scaffold["client"], scaffold["application_id"]
for to_status in ("in_review", "interview", "offer", "rejected"):
resp = client.post(
f"/v1/placements/admin/applications/{app_id}/transition",
json={"to": to_status},
headers=_admin(),
)
# rejected is terminal — the loop ends there
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == to_status
if to_status == "rejected":
break
def test_admin_garbage_to_422(scaffold) -> None:
"""G-327: `to` is a CLOSED Literal — garbage dies at pydantic
(422), never a 500/409."""
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "garbage"},
headers=_admin(),
)
assert resp.status_code == 422
def test_admin_matrix_illegal_409(scaffold) -> None:
client, app_id = scaffold["client"], scaffold["application_id"]
client.post(
f"/v1/placements/admin/applications/{app_id}/transition",
json={"to": "offer"},
headers=_admin(),
)
resp = client.post(
f"/v1/placements/admin/applications/{app_id}/transition",
json={"to": "in_review"}, # backward
headers=_admin(),
)
assert resp.status_code == 409
def test_admin_hire_requires_fields_422(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "hired"},
headers=_admin(),
)
assert resp.status_code == 422
def test_admin_withdraw_on_learners_behalf(scaffold) -> None:
"""The operator can drive ANY matrix-legal transition incl.
withdrawn (remediation with provenance, the D-083 lane)."""
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "withdrawn"},
headers=_admin(),
)
assert resp.status_code == 200
assert resp.json()["status"] == "withdrawn"
def _admin_hire(scaffold) -> str:
"""The shared hire-through-the-operator-lane helper → placement id."""
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "hired", "role_title": "Engineer", "start_date": "2026-11-01"},
headers=_admin(),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "hired"
assert body["placement"]["role_title"] == "Engineer"
assert body["placement"]["status"] == "active"
return body["placement"]["id"]
def test_admin_hire_creates_placement(scaffold) -> None:
_admin_hire(scaffold)
# -- revoke + the R-80 re-hire ------------------------------------------------------
def test_revoke_then_rehire_three_event_ledger(scaffold) -> None:
client, app_id = scaffold["client"], scaffold["application_id"]
placement_id = _admin_hire(scaffold)
# revoke
resp = client.post(
f"/v1/placements/admin/placements/{placement_id}/revoke",
headers=_admin(),
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "revoked"
# the revoked placement is out of the active set (P3's overview now
# exists — the D-102/D-107 exclusion arm)
resp = client.get("/v1/placements/admin/overview", headers=_admin())
assert resp.status_code == 200
assert resp.json()["total_active_placements"] == 0
# re-hire: the FRESH active row (R-80) + decided_at = the re-hire
# event's stamp (G-326 — asserted at the store layer in test_store)
resp = client.post(
f"/v1/placements/admin/placements/by-application/{app_id}/re-hire",
json={"role_title": "Engineer II", "start_date": "2026-12-15"},
headers=_admin(),
)
assert resp.status_code == 200, resp.text
rehired = resp.json()
assert rehired["status"] == "active"
assert rehired["id"] != placement_id
assert rehired["role_title"] == "Engineer II"
def test_revoke_unknown_404(scaffold) -> None:
resp = scaffold["client"].post(
"/v1/placements/admin/placements/plc-nonexistent/revoke",
headers=_admin(),
)
assert resp.status_code == 404
def test_rehire_second_active_409(scaffold) -> None:
"""The partial-unique arm: re-hire WITHOUT revoke → the honest 409."""
client, app_id = scaffold["client"], scaffold["application_id"]
client.post(
f"/v1/placements/admin/applications/{app_id}/transition",
json={"to": "hired", "role_title": "Engineer", "start_date": "2026-11-01"},
headers=_admin(),
)
resp = client.post(
f"/v1/placements/admin/placements/by-application/{app_id}/re-hire",
json={"role_title": "Engineer II", "start_date": "2026-12-15"},
headers=_admin(),
)
assert resp.status_code == 409
assert "revoke it before re-hiring" in resp.json()["detail"]
def test_rehire_requires_hired_application_409(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/placements/admin/placements/by-application/{scaffold['application_id']}/re-hire",
json={"role_title": "Engineer", "start_date": "2026-11-01"},
headers=_admin(),
)
assert resp.status_code == 409 # not hired yet — the correction lane refuses
@@ -0,0 +1,298 @@
"""Employer application transition tests (D-105, REQ-11-002) — the
G-311 gate-order pins + the D-099 matrix 409 arms + the G-319 pin
split + the hire arms."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from ai_service.employers.store import SQLiteEmployerStore
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import make_client
# -- fixtures: the employer-principal + application scaffold -------------------
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
import uuid
# the claim route requires the EmployerRow entity to exist (404 unknown)
from ai_service.marketplace.base import Employer
store.upsert_employer(
Employer(
id=employer_id,
source="seed-corpus",
external_id=employer_id,
name="Test Labs",
website="https://example.com/test",
)
)
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
description="Test posting",
)
)
# the learner apply route requires published (409 on drafts)
store.set_posting_status(posting.id, "published")
return posting.id
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
resp = client.post(
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
)
assert resp.status_code == 200, resp.text
return resp.json()["application_id"]
def _register_and_claim(
client: TestClient, employer_store: SQLiteEmployerStore, email: str
) -> str:
"""Register + claim + admin-approve → (token, claimed employer_id)."""
resp = client.post(
"/v1/employers/auth/register",
json={"email": email, "password": "hunter2hunter2"},
)
assert resp.status_code == 201, resp.text
token = resp.json()["token"]
resp = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-test"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 201), resp.text
claim_id = resp.json()["id"]
resp = client.post(
f"/v1/employers/admin/claims/{claim_id}/approve",
headers={"X-Admin-Token": "test-admin-token"},
)
assert resp.status_code == 200, resp.text
return token
def _make(tmp_path, identity_store, marketplace_store):
"""Build the client + the employer store seam (the P2 scaffold)."""
client = make_client(tmp_path, identity_store, marketplace_store)
employer_store = SQLiteEmployerStore(db_path=tmp_path / "shared.db")
client.app.state.employer_store = ( # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
employer_store
)
return client, employer_store
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client, employer_store = _make(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store, "emp-test")
token = _register_and_claim(entered, employer_store, "hiring@example.com")
application_id = _apply(entered, posting_id)
yield {
"client": entered,
"token": token,
"posting_id": posting_id,
"application_id": application_id,
}
def _hdr(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# -- G-311 gate order (never a 409 before a 403; never a 403 before the 401) -----
def test_gate_order_no_token_401(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/employers/applications/{scaffold['application_id']}/in-review"
)
assert resp.status_code == 401
def test_gate_order_unclaimed_403(tmp_path, identity_store, marketplace_store) -> None:
client, _ = _make(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store, "emp-test")
resp = entered.post(
"/v1/employers/auth/register",
json={"email": "unclaimed@example.com", "password": "hunter2hunter2"},
)
token = resp.json()["token"]
application_id = _apply(entered, posting_id)
resp = entered.post(
f"/v1/employers/applications/{application_id}/in-review",
headers=_hdr(token),
)
assert resp.status_code == 403
assert "no_claimed_employer" in str(resp.json()["detail"])
def test_gate_order_foreign_application_403(scaffold, tmp_path, identity_store,
marketplace_store) -> None:
"""The cross-entity arm: another entity's posting's application →
403 with NO application data (anti-enumeration, G-319)."""
client = scaffold["client"]
foreign_posting = _seed_posting(marketplace_store, "emp-other")
foreign_app = _apply(client, foreign_posting)
resp = client.post(
f"/v1/employers/applications/{foreign_app}/in-review",
headers=_hdr(scaffold["token"]),
)
assert resp.status_code == 403
assert resp.json()["detail"] == (
"this application belongs to another employer entity"
)
# -- the pipeline list + G-319 pin split ----------------------------------------
def test_list_own_applications_with_learner_id(scaffold) -> None:
client = scaffold["client"]
resp = client.get("/v1/employers/applications", headers=_hdr(scaffold["token"]))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["total"] == 1
app_row = body["applications"][0]
# G-319 supersession: learner_id IS visible on the application-scoped read
assert app_row["learner_id"] == "pilot-learner"
assert app_row["status"] == "submitted"
assert body["facets"]["submitted"] == 1
assert body["facets"]["hired"] == 0
def test_own_postings_list_stays_counts_only(scaffold) -> None:
"""The G-319 pin split: the own-postings LIST never carries learner
identifiers (the v0.10 boundary holds there — unchanged)."""
client = scaffold["client"]
resp = client.get("/v1/employers/postings", headers=_hdr(scaffold["token"]))
assert resp.status_code == 200
assert "pilot-learner" not in resp.text
# -- per-target transitions + the D-099 matrix 409s -----------------------------
def test_full_pipeline_transitions(scaffold) -> None:
client, token = scaffold["client"], scaffold["token"]
app_id = scaffold["application_id"]
for target in ("in-review", "interview", "offer"):
resp = client.post(
f"/v1/employers/applications/{app_id}/{target}", headers=_hdr(token)
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == target.replace("-", "_")
resp = client.post(
f"/v1/employers/applications/{app_id}/hire",
json={"role_title": "AI Orchestration Engineer", "start_date": "2026-11-01"},
headers=_hdr(token),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "hired"
assert body["placement"]["role_title"] == "AI Orchestration Engineer"
assert body["placement"]["start_date"] == "2026-11-01"
assert body["placement"]["employer_id"] == "emp-test"
def test_skip_forward_hire_from_submitted(scaffold) -> None:
"""R-71: submitted → hired is a legal fast-track."""
client, token = scaffold["client"], scaffold["token"]
resp = client.post(
f"/v1/employers/applications/{scaffold['application_id']}/hire",
json={"role_title": "Engineer", "start_date": "2026-12-01"},
headers=_hdr(token),
)
assert resp.status_code == 200
assert resp.json()["status"] == "hired"
def test_matrix_backward_move_409(scaffold) -> None:
client, token = scaffold["client"], scaffold["token"]
app_id = scaffold["application_id"]
client.post(
f"/v1/employers/applications/{app_id}/offer", headers=_hdr(token)
)
resp = client.post(
f"/v1/employers/applications/{app_id}/in-review", headers=_hdr(token)
)
assert resp.status_code == 409
def test_matrix_terminal_source_409(scaffold) -> None:
client, token = scaffold["client"], scaffold["token"]
app_id = scaffold["application_id"]
client.post(
f"/v1/employers/applications/{app_id}/hire",
json={"role_title": "Engineer", "start_date": "2026-12-01"},
headers=_hdr(token),
)
resp = client.post(
f"/v1/employers/applications/{app_id}/reject", headers=_hdr(token)
)
assert resp.status_code == 409
def test_hire_requires_role_title_and_start_date_422(scaffold) -> None:
client, token = scaffold["client"], scaffold["token"]
resp = client.post(
f"/v1/employers/applications/{scaffold['application_id']}/hire",
json={"role_title": "", "start_date": "2026-12-01"},
headers=_hdr(token),
)
# G-324: min_length=1 → the empty string dies at pydantic (422)
assert resp.status_code == 422
resp = client.post(
f"/v1/employers/applications/{scaffold['application_id']}/hire",
json={"role_title": "Engineer"},
headers=_hdr(token),
)
assert resp.status_code == 422
def test_unknown_application_404(scaffold) -> None:
resp = scaffold["client"].post(
"/v1/employers/applications/app-nonexistent/in-review",
headers=_hdr(scaffold["token"]),
)
assert resp.status_code == 404
def test_withdraw_never_offered_to_employers(scaffold) -> None:
"""D-099: employers reject — there is NO employer withdraw route."""
client = scaffold["client"]
resp = client.post(
f"/v1/employers/applications/{scaffold['application_id']}/withdraw",
headers=_hdr(scaffold["token"]),
)
assert resp.status_code in (404, 405)
# -- the P2-review pin (P2-2 disposition): the D-089 audit lane -------------------
def test_transition_appends_employer_audit_row(scaffold) -> None:
"""P2-2 pin: every employer transition appends its
employer_audit_events row (kind='application' — the vocabulary
extended in this phase; the one-queryable lane)."""
scaffold["client"].post(
f"/v1/employers/applications/{scaffold['application_id']}/in-review",
headers=_hdr(scaffold["token"]),
)
store = scaffold["client"].app.state.employer_store
events = store.list_audit_events(kind="application")
assert len(events) == 1
assert events[0].name == "application.in_review"
assert events[0].application_id if False else True # noqa: the id rides detail
assert events[0].detail["application_id"] == scaffold["application_id"]
assert events[0].detail["to_status"] == "in_review"
@@ -0,0 +1,283 @@
"""The audit-export tests (D-108/G-321/G-325, REQ-11-004) — re-export
determinism, chain-head consistency, methodology completeness, the
G-325 evidence bundles, fail-closed gates."""
from __future__ import annotations
import json
import uuid
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer, Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import ADMIN_TOKEN, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
store.upsert_employer(
Employer(
id=employer_id,
source="seed-corpus",
external_id=employer_id,
name=f"Labs {employer_id}",
)
)
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:10]}",
source="admin",
external_id=f"t-{uuid.uuid4().hex[:10]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
store.set_posting_status(posting.id, "published")
return posting.id
def _hire_direct(
client: TestClient,
marketplace: SQLiteMarketplaceStore,
posting_id: str,
learner_id: str,
start_date: str = "2026-11-01",
) -> str:
from datetime import date
placements = client.app.state.placement_store # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
application, _ = marketplace.insert_application(posting_id, learner_id)
_, placement = placements.transition_application(
application.id,
"hired",
actor_type="operator",
actor_ref="operator",
role_title="Engineer",
start_date=date.fromisoformat(start_date),
)
assert placement is not None
return placement.id
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
yield {"client": entered, "marketplace": marketplace_store}
def _export(client: TestClient, token: str = ADMIN_TOKEN) -> dict:
resp = client.get(
"/v1/placements/admin/export", headers={"X-Admin-Token": token}
)
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith("application/json")
return json.loads(resp.text)
# -- the G-321/G-325/R-73 contract -----------------------------------------------
def test_reexport_determinism(scaffold) -> None:
"""R-73: two exports on an unchanged DB → identical ledger chains
+ identical head_hash, generated_at EXCLUDED from comparison."""
s = scaffold
posting_id = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
first = _export(s["client"])
second = _export(s["client"])
# generated_at differs (a function of the request) — everything
# else is byte-identical (the TOTAL-exclusion rule: strip the key)
first.pop("generated_at")
second.pop("generated_at")
assert first == second
assert first["ledger"]["events"] == second["ledger"]["events"]
assert first["ledger"]["head_hash"] == second["ledger"]["head_hash"]
def test_chain_head_consistency(scaffold) -> None:
"""head_hash = the last event's hash = verify_chain's output."""
s = scaffold
posting_id = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
body = _export(s["client"])
events = body["ledger"]["events"]
assert body["ledger"]["head_hash"] == events[-1]["hash"]
placements = s["client"].app.state.placement_store
ok, head = placements.verify_chain()
assert ok is True
assert head == body["ledger"]["head_hash"]
def test_methodology_completeness(scaffold) -> None:
"""G-321 + R-67/R-68: all four methodology keys present; the three
pilot gaps stated explicitly; the chain proof-scope + continuity
anchor + cross-check stated."""
body = _export(scaffold["client"])
methodology = body["methodology"]
for key in ("placement_definition", "counting_rules", "verification", "scope"):
assert key in methodology, f"methodology missing {key}"
verification = methodology["verification"]
scope = methodology["scope"]
# the chain's proof scope + limit + anchor (G-321)
assert "WHAT THE CHAIN PROVES" in verification
assert "WHAT IT DOES NOT PROVE" in verification
assert "export-to-export continuity" in verification or "PREFIX" in verification
assert "revoked_count" in verification # the (c) cross-check
# the three R-67 pilot gaps (a) (b) (c) stated in scope
assert "minimum-employed-duration" in scope or "duration" in scope
assert "salary" in scope
assert "NOT third-party employment verification" in scope
# employer-attested evidence class named
assert "attestation" in verification
def test_evidence_bundles_cover_revoked_too(scaffold) -> None:
"""G-325: bundles cover EVERY placement (active AND revoked) with
the full event history; cohort stays active-only."""
s = scaffold
posting_id = _seed_posting(s["marketplace"], "emp-a")
placement_id = _hire_direct(s["client"], s["marketplace"], posting_id,
"pilot-learner")
placements = s["client"].app.state.placement_store
placements.revoke_placement(placement_id, "operator")
body = _export(s["client"])
assert body["summary"]["revoked_count"] == 1
assert body["cohort"] == [] # active-only
assert len(body["evidence_bundles"]) == 1 # the revoked row IS bundled
bundle = body["evidence_bundles"][0]
assert bundle["placement_status"] == "revoked"
assert bundle["posting_record"] is not None
assert bundle["application_record"] is not None
event_types = [e["event_type"] for e in bundle["transition_history"]]
assert "application.hired" in event_types
assert "placement.created" in event_types
assert "placement.revoked" in event_types
# the (c) cross-check: revoked_count == the placement.revoked events
revoked_events = [
e for e in body["ledger"]["events"] if e["event_type"] == "placement.revoked"
]
assert body["summary"]["revoked_count"] == len(revoked_events)
def test_export_fail_closed(scaffold) -> None:
resp = scaffold["client"].get("/v1/placements/admin/export")
assert resp.status_code == 403
resp = scaffold["client"].get("/v1/placements/admin/export/summary")
assert resp.status_code == 403
def test_summary_renders_from_the_same_document(scaffold) -> None:
"""D-108's anti-drift rule: the summary's totals equal the export's
summary block on the same DB state; the content type is markdown;
the G-328 attestation title + the head hash present."""
s = scaffold
posting_id = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
export = _export(s["client"])
resp = s["client"].get(
"/v1/placements/admin/export/summary", headers={"X-Admin-Token": ADMIN_TOKEN}
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/markdown")
text = resp.text
assert "Employer-attested, operator-overseen" in text # G-328 title
assert f"| {export['summary']['distinct_learners_placed']} |" in text
assert export["ledger"]["head_hash"] in text # the walk instructions
assert "PREFIX" in text # the export-to-export continuity anchor
# -- the full-chain E2E proof (the milestone's reason to exist) -------------------
def test_e2e_audit_proof(scaffold) -> None:
"""The machine-checked E2E (D-111): register → claim → admin
approve → posting → verified learner applies → transitions →
hire → placement created → chain green → export reproduces →
counts the learner ONCE."""
from ai_service.employers.store import SQLiteEmployerStore
s = scaffold
client = s["client"]
# fresh employer store on the shared file (the scaffold's client
# wires one only through the lifespan default — build it here)
import pathlib
db_path = pathlib.Path(
str(client.app.state.marketplace_store._db_path) # the shared tmp file (test seam)
)
employer_store = SQLiteEmployerStore(db_path=db_path)
client.app.state.employer_store = employer_store
posting_id = _seed_posting(s["marketplace"], "emp-a")
# employer registers → claims → operator approves
resp = client.post(
"/v1/employers/auth/register",
json={"email": "e2e@example.com", "password": "hunter2hunter2"},
)
assert resp.status_code == 201
token = resp.json()["token"]
resp = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-a"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 201)
claim_id = resp.json()["id"]
resp = client.post(
f"/v1/employers/admin/claims/{claim_id}/approve",
headers={"X-Admin-Token": ADMIN_TOKEN},
)
assert resp.status_code == 200
# verified learner applies
resp = client.post(
"/v1/marketplace/apply",
json={"posting_id": posting_id, "learner_id": "pilot-learner"},
)
assert resp.status_code == 200
application_id = resp.json()["application_id"]
# employer drives in_review → interview → offer → hire
for target in ("in-review", "interview", "offer"):
resp = client.post(
f"/v1/employers/applications/{application_id}/{target}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
resp = client.post(
f"/v1/employers/applications/{application_id}/hire",
json={"role_title": "AI Orchestration Engineer", "start_date": "2026-11-01"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["placement"]["status"] == "active"
# the chain walks green
placements = client.app.state.placement_store
ok, _ = placements.verify_chain()
assert ok is True
# the export reproduces (chain + head identical, generated_at excluded)
first = _export(client)
first.pop("generated_at")
second = _export(client)
second.pop("generated_at")
assert first == second
# the overview counts the learner ONCE toward 1,000
resp = client.get(
"/v1/placements/admin/overview", headers={"X-Admin-Token": ADMIN_TOKEN}
)
assert resp.status_code == 200
overview = resp.json()
assert overview["distinct_learners_placed"] == 1
assert overview["total_active_placements"] == 1
assert overview["guiding_goal"] == 1000
assert overview["by_cohort_month"] == [{"month": "2026-11", "count": 1}]
@@ -0,0 +1,260 @@
"""Learner application-status tests (D-104/G-322, REQ-11-005) — the
mine-read discovery arms, the status read's scoped-history + hired
placement arms, the revoked honest-null arm, and the reads-stay-free
pin (no self-429, G-313)."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import ADMIN_TOKEN, PLACEMENT_LEARNERS, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
import uuid
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
# the learner apply route requires published (409 on drafts)
store.set_posting_status(posting.id, "published")
return posting.id
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
resp = client.post(
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
)
assert resp.status_code == 200, resp.text
return resp.json()["application_id"]
def _admin(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
def _transition(client: TestClient, application_id: str, body: dict) -> None:
resp = client.post(
f"/v1/placements/admin/applications/{application_id}/transition",
json=body,
headers=_admin(),
)
assert resp.status_code == 200, resp.text
def _status(client: TestClient, application_id: str, learner_id: str):
return client.get(
f"/v1/marketplace/applications/{application_id}",
params={"learner_id": learner_id},
)
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
application_id = _apply(entered, posting_id)
yield {
"client": entered,
"posting_id": posting_id,
"application_id": application_id,
}
# -- the mine-read (G-322): discovery on revisit -------------------------------
def test_mine_own_200(scaffold) -> None:
resp = scaffold["client"].get(
"/v1/marketplace/applications/mine",
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["application_id"] == scaffold["application_id"]
assert body["status"] == "submitted"
def test_mine_none_404(scaffold) -> None:
resp = scaffold["client"].get(
"/v1/marketplace/applications/mine",
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner-2"},
)
assert resp.status_code == 404
def test_mine_unallowlisted_403(scaffold) -> None:
resp = scaffold["client"].get(
"/v1/marketplace/applications/mine",
params={"posting_id": scaffold["posting_id"], "learner_id": "intruder"},
)
assert resp.status_code == 403
def test_mine_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
"""A learner on the roster but WITHOUT a verified identity record →
the verified-18+ arm refuses (the D-043 composition)."""
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
from ..conftest import SUITE_LEARNERS
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
resp = entered.get(
"/v1/marketplace/applications/mine",
params={"posting_id": posting_id, "learner_id": unverified},
)
assert resp.status_code == 403
# -- the status read (D-104): status + scoped history + placement ---------------
def test_status_own_200_with_events(scaffold) -> None:
client = scaffold["client"]
_transition(client, scaffold["application_id"], {"to": "in_review"})
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["application"]["id"] == scaffold["application_id"]
assert body["application"]["posting_id"] == scaffold["posting_id"]
assert body["application"]["learner_id"] == "pilot-learner"
assert body["application"]["status"] == "in_review"
assert [e["event_type"] for e in body["events"]] == ["application.in_review"]
assert body["placement"] is None
def test_status_events_scoped_and_id_ordered(scaffold) -> None:
"""The events arm is a SCOPED query: another learner's events never
ride this read, and the history renders id-ordered (the chain
order)."""
client = scaffold["client"]
import uuid
store = client.app.state.marketplace_store
posting2 = store.insert_posting(
Posting(
id=f"post-b-{uuid.uuid4().hex[:6]}",
source="admin",
external_id=f"b-{uuid.uuid4().hex[:8]}",
employer_id="emp-test",
title="Second posting",
)
)
store.set_posting_status(posting2.id, "published")
other = _apply(client, posting2.id, learner_id="pilot-learner-2")
_transition(client, other, {"to": "interview"})
_transition(client, scaffold["application_id"], {"to": "in_review"})
_transition(client, scaffold["application_id"], {"to": "interview"})
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert [e["event_type"] for e in body["events"]] == [
"application.in_review",
"application.interview",
]
assert all(
e["application_id"] == scaffold["application_id"] for e in body["events"]
)
def test_status_hired_carries_placement(scaffold) -> None:
client = scaffold["client"]
_transition(
client,
scaffold["application_id"],
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
)
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["application"]["status"] == "hired"
placement = body["placement"]
assert placement is not None
assert placement["role_title"] == "Placement Engineer"
assert placement["start_date"] == "2026-11-02"
assert placement["application_id"] == scaffold["application_id"]
assert placement["learner_id"] == "pilot-learner"
assert placement["employer_id"] == "emp-test"
assert placement["posting_id"] == scaffold["posting_id"]
# hired → the two-event chain: application.hired + placement.created
assert [e["event_type"] for e in body["events"]] == [
"application.hired",
"placement.created",
]
def test_status_revoked_placement_is_honest_null(scaffold) -> None:
"""A-304: no active placement record renders after the operator
revoke — the honest null (the events carry the correction trail,
never a fabricated badge)."""
client = scaffold["client"]
app_id = scaffold["application_id"]
_transition(
client,
app_id,
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
)
placement_id = _status(client, app_id, "pilot-learner").json()["placement"]["id"]
resp = client.post(
f"/v1/placements/admin/placements/{placement_id}/revoke",
headers=_admin(),
)
assert resp.status_code == 200, resp.text
resp = _status(client, app_id, "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["placement"] is None
assert body["events"][-1]["event_type"] == "placement.revoked"
def test_status_foreign_learner_403(scaffold) -> None:
resp = _status(scaffold["client"], scaffold["application_id"], "pilot-learner-2")
assert resp.status_code == 403
def test_status_unallowlisted_403(scaffold) -> None:
resp = _status(scaffold["client"], scaffold["application_id"], "intruder")
assert resp.status_code == 403
def test_status_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
from ..conftest import SUITE_LEARNERS
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
marketplace_store.insert_application(posting_id, unverified)
application = marketplace_store.list_applications(posting_id)[0]
resp = _status(entered, application.id, unverified)
assert resp.status_code == 403
def test_status_unknown_404(scaffold) -> None:
resp = _status(scaffold["client"], "app-nonexistent", "pilot-learner")
assert resp.status_code == 404
# -- G-313: reads never consume the apply-rate budget ----------------------------
def test_status_reads_stay_free_no_self_429(scaffold) -> None:
"""A-408's distinct-WRITE rule: status reads stay free — a revisit
storm of reads never 429s (the conftest reset proves the next
test starts clean either way)."""
client = scaffold["client"]
for _ in range(10):
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
@@ -0,0 +1,226 @@
"""Learner withdraw tests (D-104/G-323, REQ-11-002) — the D-043 gate
stack + the terminal arms + the apply-route terminal guard."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import PLACEMENT_LEARNERS, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
import uuid
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
# the learner apply route requires published (409 on drafts)
store.set_posting_status(posting.id, "published")
return posting.id
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
resp = client.post(
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
)
assert resp.status_code == 200, resp.text
return resp.json()["application_id"]
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
application_id = _apply(entered, posting_id)
yield {
"client": entered,
"posting_id": posting_id,
"application_id": application_id,
}
# -- the D-043 gate stack (G-5 → verified 18+ → the apply-rate window) ------------
def test_withdraw_own_submitted(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "withdrawn"
def test_withdraw_unallowlisted_403(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "intruder"},
)
assert resp.status_code == 403
def test_withdraw_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
"""A learner on the roster but WITHOUT a verified identity record →
the verified-18+ arm refuses (the D-043 composition)."""
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
# an allowlisted-but-unverified learner: the suite roster carries
# learners beyond PLACEMENT_LEARNERS (the tests/conftest SUITE
# list); the identity store only seeds PLACEMENT_LEARNERS.
from ..conftest import SUITE_LEARNERS
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
# even the APPLY is refused for an unverified learner (the same
# D-043 stack) — the withdraw arm pins identically: build the
# application row directly through the store, then attempt the
# withdraw through the route.
marketplace_store.insert_application(posting_id, unverified)
application = marketplace_store.list_applications(posting_id)[0]
resp = entered.post(
f"/v1/marketplace/applications/{application.id}/withdraw",
json={"learner_id": unverified},
)
assert resp.status_code == 403
def test_withdraw_foreign_learner_403(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner-2"},
)
assert resp.status_code == 403
def test_withdraw_terminal_409(scaffold) -> None:
client = scaffold["client"]
client.post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
resp = client.post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 409
def test_withdraw_unknown_404(scaffold) -> None:
resp = scaffold["client"].post(
"/v1/marketplace/applications/app-nonexistent/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 404
# -- G-323: apply on a terminal application → 409, never a 200 lie --------------
def test_reapply_after_withdraw_409(scaffold) -> None:
client = scaffold["client"]
client.post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
resp = client.post(
"/v1/marketplace/apply",
json={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
)
assert resp.status_code == 409
assert resp.json()["detail"]["reason"] == "application_terminal"
assert resp.json()["detail"]["status"] == "withdrawn"
def test_apply_idempotent_still_200_on_submitted(scaffold) -> None:
"""A-408 unchanged: a duplicate apply on a LIVE application still
returns the 200 with the existing row (G-323 guards only terminals)."""
resp = scaffold["client"].post(
"/v1/marketplace/apply",
json={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
)
assert resp.status_code == 200
assert resp.json()["application_id"] == scaffold["application_id"]
# -- the P2-review pins (P2-1/P2-2 disposition): provenance + budget -------------
def test_withdraw_ledger_actor_is_learner(scaffold) -> None:
"""D-104 pin: the withdraw transition's ledger event records
actor_type='learner' with the learner's actor_ref."""
scaffold["client"].post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
placements = scaffold["client"].app.state.placement_store
events = placements.get_application_events(scaffold["application_id"])
assert len(events) == 1
assert events[0].event_type == "application.withdrawn"
assert events[0].actor_type == "learner"
assert events[0].actor_ref == "learner:pilot-learner"
def test_withdraw_rides_the_apply_rate_window(scaffold) -> None:
"""D-104/G-313 pin: withdraw shares the apply-rate window. Budget:
the scaffold's apply consumed 1; two withdraws (2,3) + one apply
(4) exhaust the 5/60s window; the NEXT gated action 429s honestly
(the conftest reset proves the next test starts clean)."""
client = scaffold["client"]
import uuid
from ai_service.marketplace.base import Posting
store = client.app.state.marketplace_store
# withdraw the scaffold application (action 2)
resp = client.post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 200, resp.text
# a second application: apply (action 3) + withdraw (action 4)
posting = store.insert_posting(
Posting(
id=f"post-w-{uuid.uuid4().hex[:6]}",
source="admin",
external_id=f"w-{uuid.uuid4().hex[:8]}",
employer_id="emp-test",
title="Posting",
)
)
store.set_posting_status(posting.id, "published")
application_id = _apply(client, posting.id)
resp = client.post(
f"/v1/marketplace/applications/{application_id}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 200, resp.text
# a third application: the apply is the 5th gated action — green;
# its withdraw would be the 6th → 429
posting2 = store.insert_posting(
Posting(
id=f"post-w2-{uuid.uuid4().hex[:6]}",
source="admin",
external_id=f"w2-{uuid.uuid4().hex[:8]}",
employer_id="emp-test",
title="Posting",
)
)
store.set_posting_status(posting2.id, "published")
application2 = _apply(client, posting2.id)
resp = client.post(
f"/v1/marketplace/applications/{application2}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 429
@@ -0,0 +1,220 @@
"""Reporting tests (D-107, REQ-11-003) — the distinct-learner rule,
cohort bucketing, revoke exclusion, the per-employer view's gates."""
from __future__ import annotations
import uuid
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer, Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from ai_service.placements.store import SQLitePlacementStore
from .conftest import ADMIN_TOKEN, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
store.upsert_employer(
Employer(
id=employer_id,
source="seed-corpus",
external_id=employer_id,
name=f"Labs {employer_id}",
)
)
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:10]}",
source="admin",
external_id=f"t-{uuid.uuid4().hex[:10]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
store.set_posting_status(posting.id, "published")
return posting.id
def _hire_direct(
placements: SQLitePlacementStore,
marketplace: SQLiteMarketplaceStore,
posting_id: str,
learner_id: str,
role_title: str = "Engineer",
start_date: str = "2026-11-01",
) -> str:
"""Direct store-level hire (the route arms are P2-tested; here we
exercise the REPORTING computation over placement rows)."""
application, _ = marketplace.insert_application(posting_id, learner_id)
_, placement = placements.transition_application(
application.id,
"hired",
actor_type="operator",
actor_ref="operator",
role_title=role_title,
start_date=__import__("datetime").date.fromisoformat(start_date),
)
assert placement is not None
return placement.id
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
placements = entered.app.state.placement_store # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
yield {
"client": entered,
"placements": placements,
"marketplace": marketplace_store,
}
def _overview(client: TestClient) -> dict:
resp = client.get(
"/v1/placements/admin/overview", headers={"X-Admin-Token": ADMIN_TOKEN}
)
assert resp.status_code == 200, resp.text
return resp.json()
def test_empty_overview_honest_zeroes(scaffold) -> None:
body = _overview(scaffold["client"])
assert body["total_active_placements"] == 0
assert body["distinct_learners_placed"] == 0
assert body["guiding_goal"] == 1000
assert body["by_employer"] == []
assert body["by_cohort_month"] == []
def test_distinct_learner_rule(scaffold) -> None:
"""D-107: a learner placed TWICE counts ONCE toward the guiding
goal (the outcome's noun is learners); both placements count in
total_active_placements."""
s = scaffold
posting_a = _seed_posting(s["marketplace"], "emp-a")
posting_b = _seed_posting(s["marketplace"], "emp-b")
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner")
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner")
body = _overview(s["client"])
assert body["total_active_placements"] == 2
assert body["distinct_learners_placed"] == 1 # placed twice, counted once
assert body["by_employer"] == [
{"employer_id": "emp-a", "count": 1},
{"employer_id": "emp-b", "count": 1},
]
def test_cohort_month_bucketing(scaffold) -> None:
"""D-107/D-103: cohort month = the calendar month of start_date —
two starts in one month share a bucket; a future-dated start
lands in its own month."""
s = scaffold
posting_a = _seed_posting(s["marketplace"], "emp-a")
posting_b = _seed_posting(s["marketplace"], "emp-a")
posting_c = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner",
start_date="2026-11-01")
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner-2",
start_date="2026-11-20")
_hire_direct(s["placements"], s["marketplace"], posting_c, "api-learner",
start_date="2027-02-05")
body = _overview(s["client"])
assert body["by_cohort_month"] == [
{"month": "2026-11", "count": 2},
{"month": "2027-02", "count": 1},
]
def test_revoke_excluded_from_counts(scaffold) -> None:
"""D-102/D-107: revoked placements are gone from every count; a
learner whose ONLY placement was revoked is back to 0."""
s = scaffold
posting = _seed_posting(s["marketplace"], "emp-a")
placement_id = _hire_direct(s["placements"], s["marketplace"], posting,
"pilot-learner")
before = _overview(s["client"])
assert before["distinct_learners_placed"] == 1
s["placements"].revoke_placement(placement_id, "operator")
after = _overview(s["client"])
assert after["total_active_placements"] == 0
assert after["distinct_learners_placed"] == 0 # back to 0
assert after["by_employer"] == []
assert after["by_cohort_month"] == []
def test_overview_fail_closed(scaffold) -> None:
resp = scaffold["client"].get("/v1/placements/admin/overview")
assert resp.status_code == 403
def test_per_employer_view_own_rows(scaffold) -> None:
"""The employer placement view: session-gated, own claimed entity
only; honest empty state (403 + claim-CTA unclaimed; 401 no token)."""
s = scaffold
posting = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["placements"], s["marketplace"], posting, "pilot-learner")
# 401 without a session
resp = s["client"].get("/v1/employers/placements")
assert resp.status_code == 401
# 403 + claim-CTA when unclaimed
resp = s["client"].post(
"/v1/employers/auth/register",
json={"email": "viewer@example.com", "password": "hunter2hunter2"},
)
token = resp.json()["token"]
resp = s["client"].get(
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 403
assert "no_claimed_employer" in str(resp.json()["detail"])
# the honest empty state for a claimed entity with zero placements
store = s["client"].app.state.marketplace_store
store.upsert_employer(
Employer(
id="emp-b",
source="seed-corpus",
external_id="emp-b",
name="Labs emp-b",
)
)
resp = s["client"].post(
"/v1/employers/claims",
json={"employer_id": "emp-b"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 201), resp.text
claim_id = resp.json()["id"]
s["client"].post(
f"/v1/employers/admin/claims/{claim_id}/approve",
headers={"X-Admin-Token": ADMIN_TOKEN},
)
resp = s["client"].get(
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
assert resp.json()["total"] == 0
assert resp.json()["placements"] == []
def test_by_employer_ordering(scaffold) -> None:
"""R-73: by_employer is count desc then employer_id ascending."""
s = scaffold
posting_a1 = _seed_posting(s["marketplace"], "emp-a")
posting_b1 = _seed_posting(s["marketplace"], "emp-b")
posting_b2 = _seed_posting(s["marketplace"], "emp-b")
_hire_direct(s["placements"], s["marketplace"], posting_a1, "pilot-learner")
_hire_direct(s["placements"], s["marketplace"], posting_b1, "pilot-learner-2")
_hire_direct(s["placements"], s["marketplace"], posting_b2, "api-learner")
body = _overview(s["client"])
assert body["by_employer"] == [
{"employer_id": "emp-b", "count": 2},
{"employer_id": "emp-a", "count": 1},
]

Some files were not shown because too many files have changed in this diff Show More