Compare commits

..

52 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
98 changed files with 20613 additions and 1237 deletions
+37 -2
View File
@@ -49,9 +49,9 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
**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/upgraded, 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.
**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, mounted only when provider=mock). 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).
**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`.
@@ -59,6 +59,41 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
**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.
-8
View File
@@ -1,8 +0,0 @@
{
"phase": 5,
"stage": "verify",
"milestone": "v0.9",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-30T01:00:00Z"
}
+130
View File
@@ -263,3 +263,133 @@ None. All four seams remain founder-locked via D-016 / REQ-5-001..007; every fin
- 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
+41 -40
View File
@@ -2,13 +2,13 @@
## Persona Roster
> **v0.9 update (RESEARCH, lead-developer assessment):** milestone = Payment Engine (Feature — ai_service/billing/ module: provider protocol + mock + stripe impl, 7th D-027 store, /v1/billing routes with HMAC webhooks, posting entitlement enforcement, pricing-page + admin-surface runtime wiring). **Activated:** backend-engineer (owns ai_service/billing/* + api/billing*.py + api/deps.py + main.py router/lifespan wiring — REQ-9-001/003/004/005 core), data-engineer (owns the 7-table billing store schema: idempotency UniqueConstraints, purchase price snapshots, subscription state machine, event dedupe — REQ-9-001/005), security-auditor (phase-specific: webhook HMAC verify + replay window + raw-body discipline, fail-closed secrets, cents money handling, 402 CTA payloads — REQ-9-003), frontend-engineer (owns the pricing-page client conversion + engine-client listBillingCatalog/PaymentRequiredError + packages/types/billing.ts + admin entitlement view — REQ-9-002/004), lead-developer (coordination + wave order + dual-schema pin discipline + cross-boundary wire review). **Deactivated:** cli-engineer (no CLI changes — billing is server+web only), sandbox-engineer, devops-engineer (no infra changes; the existing release lane carries the new code automatically), ai-engineer, identity-engineer, voice-engineer, design-system-engineer.
> **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.9 Payment Engine milestone (foundation → catalog → checkout → enforcement → lifecycle waves); owns wave order + the cross-boundary wire review (billing.ts ↔ base.py dual-schema pins, 402 CTA payload shape) + provider-boundary event normalization consistency (R-43)
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:
- turborepo
@@ -27,7 +27,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.9 DEACTIVATED — no infra changes; billing rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
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:
- docker
@@ -53,7 +53,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.9 DEACTIVATED — no CLI changes; billing is server+web only. Roster + v0.8 territory retained.
reason: v0.11 DEACTIVATED — no CLI changes; the placement surface is server+web only. Roster + v0.8 territory retained.
domain: cli
frameworks:
- node
@@ -82,7 +82,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.9 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
reason: v0.11 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
domain: infra
frameworks:
- python
@@ -106,28 +106,29 @@ territory:
```yaml
active: true
phase_specific: true
reason: v0.9 re-activated (phase-specific) — P3 webhook security review: HMAC-SHA256 verify discipline (raw-body-first, compare_digest, all-v1 iteration for secret rolls, ±300s replay window, non-v1 schemes ignored — R-38..R-40), fail-closed webhook secret (unset → per-boot random → 401s, correct for a payment surface), event dedupe integrity, cents money handling (no floats — D-081), 402 CTA payload honesty, stripe provider key redaction in errors (the embedding test precedent); P6 final review (no secrets in billing code/logs/releases)
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; AI_PAYMENT_API_KEY/AI_WEBHOOK_SECRET never logged)
- secrets-via-env-only (D-014)
- secrets-never-logged
- webhooks-fail-closed-when-unconfigured
- raw-body-before-parse (signature verification sees exact bytes)
- 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/billing/**"
- "apps/ai-service/ai_service/api/billing*.py"
- "apps/ai-service/tests/billing/**"
- "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.9 ACTIVATED — owns the billing module core: ai_service/billing/base.py (PaymentProvider protocol + pydantic contracts + canonical event names), mock.py (deterministic Stripe-shaped provider), stripe.py (form-encoded REST via shared httpx pool, Idempotency-Key per R-49), factory.py (G-11 loud fallback), catalog.py (seeded idempotent), api/billing.py + api/billing_admin.py (require_admin dep verbatim R-51, webhook route raw-body-first, entitlement composition into create_posting AFTER the admin gate), config.py trio (payment_provider/payment_api_key/webhook_secret — R-53), main.py router mounts + lifespan store wiring
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
@@ -140,15 +141,14 @@ constraints:
- secrets-via-env-only (D-014)
- idempotent-scripts
- fail-closed-admin
- never-call-cloud-in-tests (MockTransport byte-contract only, D-075)
- provider-boundary-normalization (R-43: store provider ids verbatim)
- 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/billing/**"
- "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/**"
- "apps/ai-service/tests/billing/**"
- "apps/ai-service/.env.example"
- "apps/ai-service/tests/placements/**"
- "apps/ai-service/pyproject.toml"
```
@@ -156,7 +156,7 @@ territory:
```yaml
active: true
phase_specific: false
reason: v0.9 ACTIVATED — owns the pricing-page conversion (server shell retained per A-411/pins + PricingClient component with AbortController + honest loading/error/empty states, the A-406 board-client pattern), engine-client listBillingCatalog() + PaymentRequiredError (402 dict-detail discrimination — parseError precedent), packages/types/billing.ts (dual-schema pinned), admin entitlement view in the moderation surface (per-employer active package/posts used/limit/expiry, client fetch)
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
@@ -166,19 +166,20 @@ constraints:
- component-first
- server-components-default
- no-build-time-db-reads (D-039 export build stays repo-only)
- honest-states (loading/error/empty never mocked)
- cents-in-types (number documented as cents, D-081)
- 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/billing.ts"
- "packages/types/placement.ts"
```
### ai-engineer
```yaml
active: false
phase_specific: false
reason: v0.9 DEACTIVATED — no LLM-facing work; all provider layers ship unchanged.
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
@@ -199,7 +200,7 @@ territory:
```yaml
active: true
phase_specific: false
reason: v0.9 ACTIVATED — owns the 7th D-027 store: billing/store.py with the 7-table schema (packages, plans, purchases with price snapshots — D-078, subscriptions state machine, subscription_events with verbatim event_id UniqueConstraint dedupe, checkout_sessions, billing_events audit trail), InvalidTransition domain error, transition matrix tests, purchase consumption query (newest non-expired with remaining posts), idempotent catalog seeding
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:
- sqlmodel
@@ -209,12 +210,12 @@ constraints:
- schema-first
- type-safe
- dual-schema-sync
- idempotency-by-constraint (UniqueConstraint + check-first, the insert_application pattern)
- snapshots-immutable (catalog edits never rewrite purchase history, D-078)
- 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/billing/store.py"
- "apps/ai-service/ai_service/billing/catalog.py"
- "apps/ai-service/tests/billing/test_store.py"
- "apps/ai-service/ai_service/placements/store.py"
- "apps/ai-service/tests/placements/test_store.py"
- "packages/types/**"
```
@@ -222,7 +223,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.5 custom persona — module shipped complete; no v0.9 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
@@ -241,7 +242,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.5 territory — voice shipped complete; no v0.9 voice work.
reason: v0.5 territory — voice shipped complete; no v0.11 voice work.
domain: ai-media
frameworks:
- httpx
@@ -262,7 +263,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: No design-token or primitive work in v0.9; 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
@@ -278,7 +279,7 @@ territory:
| Persona | Phases | Removed After |
|---------|--------|---------------|
| security-auditor | 1 (runner + secrets), 2 (image non-root/volumes), 3 (deb postinst), 6 (final channel-consistency 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.
@@ -286,9 +287,9 @@ All other personas span the milestone. Deactivated personas receive no tasks.
| Conflict | Resolution |
|----------|------------|
| devops-engineer vs cli-engineer (build-binary.mjs invocation) | devops-engineer owns the WORKFLOW steps that invoke `pnpm --filter @nextcraft/cli build:binary`; cli-engineer owns the SCRIPT itself (arch parameter, arm64 base, sidecars) — the workflow never patches build scripts |
| devops-engineer vs sandbox-engineer (D-068 in Dockerfile) | devops-engineer owns the Dockerfile layers (base, user, healthcheck, volume); sandbox-engineer owns the runtime capability probe + degrade semantics the image merely runs; NC_CONTAINER marker name is a shared contract (devops sets it in the entrypoint, cli-engineer + sandbox-engineer read it) |
| cli-engineer vs devops-engineer (doctor container facts) | cli-engineer owns doctor/verify/guide implementations; devops-engineer supplies the documented facts (volume path, privileged variant recipe) via deploy/README.md |
| sandbox-engineer vs backend-engineer (main.py boot notice) | sandbox-engineer owns the D-068 boot notice + api/sandboxes.py degrade arm this milestone; backend-engineer is deactivated but its config.py territory is NOT modified (NC_CONTAINER lives in the CLI layer, not Settings) |
| cli-engineer vs security-auditor (postinst/secrets) | cli-engineer implements; security-auditor reviews fail-never-postinst + token resolution and may patch security defects directly in scripts/build-deb.sh + release-assets.sh (its territory) |
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
| 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 |
+135 -340
View File
@@ -1,403 +1,198 @@
# Nextcraft — PLAN.md (v0.9 Payment Engine)
# Nextcraft — PLAN.md (v0.11 CIRR-Style Placement Tracking and Audit)
## Overview
**Milestone:** v0.9 — Payment Engine (Feature, D-074): payment processing and subscription management — 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 — behind a provider-agnostic `PaymentProvider` protocol, mock-first (D-014 pattern; tests never call Stripe). The pricing page (REQ-017's surface) gains its real backend.
**Tag line:** phase tags advance the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release), continuing from the v0.7.6 milestone release
**Branch:** milestone/v0.9-payment-engine
**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-9-001 | Billing foundation: billing/ module (protocol + mock + store + HMAC + factory + settings), no routes yet |
| P2 | REQ-9-002 | Catalog + pricing surface: seeded catalog, /v1/billing/catalog, pricing-page client conversion, types + pins |
| P3 | REQ-9-003 | Checkout + webhook flow: admin checkout route, stripe provider, public webhook (raw-body HMAC, dedupe, idempotent processing), mock-complete E2E path |
| P4 | REQ-9-004 | Entitlement enforcement: consume_posting in create_posting, 402 CTA payload, admin entitlement view |
| P5 | REQ-9-005 | Subscription lifecycle: state machine via webhooks, manual grant/revoke, billing event log, past_due grace |
| P6 | — | Final review + audit + milestone ship (v0.8.6 = milestone release) |
| 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 (binding): foundation before surface before flow before enforcement before lifecycle.** P1 builds the module everything else composes (store + protocol + mock — P2's catalog route reads the store, P3's checkout writes it). P2 precedes P3 because checkout references catalog items by id (the catalog must exist + be surfaced before sessions can be created against it). P3 precedes P4 because enforcement consumes entitlements that only the webhook flow grants. P5 needs the full event set the flow produces. Each phase is a vertical slice: store rows → routes → web surface → tests green → 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.
**Binding contracts:** clarify decisions D-075..D-084 (PROJECT.md) and research R-33..R-54 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates.
**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 (probe-verified, R-51..R-54):** require_admin dep pattern at api/marketplace_admin.py:46-85; D-027 store family replication (marketplace/store.py the template); Settings env trio precedent (config.py: provider selector + SecretStr key + per-boot-random secret); HMAC precedent telemetry/tokens.py; at-least-once dedupe precedent insert_application; pricing page inline constants at page.tsx:41-111 + metadata pinned by pins.test.ts:189-190 + sitemap STATIC_PAGES; engine-client jsonFetch/parseError at lines 90-140; main.py inline router mounts + lifespan state-injection + G-11 fallback pattern.
**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).
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to billing + the pricing/admin surfaces.
**Verification commands (phase gates — the repo's own scripts):**
**Secrets policy (binding):** AI_PAYMENT_API_KEY / AI_WEBHOOK_SECRET env-only (D-014), SecretStr in settings, never logged, never in .env.example with real values; tests use fixed test secrets; the stripe provider redacts keys from exceptions (embedding test precedent).
- `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)
**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.
**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: Billing Foundation (REQ-9-001)
## Phase 1: Placement Store + Evidence Ledger (REQ-11-001)
**Goal:** The billing module exists and is proven: protocol + mock provider + 7-table store + HMAC webhook verification + factory + settings — all tested, zero routes mounted, zero web changes.
**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: Module core (backend-engineer + data-engineer, sequential)
### Wave 1-1: Module core — contracts + matrix + schema (backend-engineer + data-engineer, sequential)
- **Task 1-1-01** (backend-engineer): `ai_service/billing/__init__.py` + `base.py` — pydantic contracts: `Package` (id, name, price_cents, posts_limit, duration_days, features), `Plan` (id, name, price_cents, interval, features), `CheckoutSessionSpec` (mode, line item ref, success/cancel refs, client_reference_id, metadata), `CheckoutSession` (id, url, status, payment_status, mode, expires_at), `SubscriptionRef`, `CanonicalBillingEvent` (name enum: checkout.completed, invoice.paid, invoice.payment_failed, subscription.cancelled, subscription.renewed + payload dict). Money = int cents ge=0 (D-081). `PaymentProvider` Protocol: create_checkout_session, retrieve_session, cancel_subscription, update_subscription, verify_webhook, normalize_event.
- **Task 1-1-02** (data-engineer): `billing/store.py` — 7th D-027 store (R-52): 7 tables per D-080; UniqueConstraints (subscription_events.event_id, checkout_sessions.provider_session_id, **purchases.checkout_session_id — G-303: grants idempotent per session, not per event id**); purchases carry price/name snapshot columns; `_validate`/`_normalize`/`_as_utc`/`_sqlite_connect` (WAL/NORMAL/busy_timeout/foreign_keys); Protocol + SQLiteBillingStore pair; `InvalidTransition(ValueError)`; store methods: seed_catalog (idempotent upsert), list_catalog, get_purchase/consume_posting(employer_id) (**G-304: single conditional UPDATE `SET posts_used = posts_used + 1 WHERE id = <newest non-expired with remaining> AND posts_used < posts_limit`, rowcount-checked — never check-then-increment; 0 rows → PackageExhausted/NoActivePackage**), subscription get/upsert/transition (state machine per D-083; **upsert keyed on provider subscription id — G-303**), insert_provider_event (check-first dedupe → created flag), insert_billing_event (audit, manual provenance, **unmatched marker per G-302**), close. Tests: `tests/billing/test_store.py` — tmp_path db, table-existence via sqlite_master, pragma pins, IntegrityError surfacing, dedupe idempotency double-insert (**G-303: same session, different event ids → one purchase**), transition matrix (valid + InvalidTransition; **G-305: no `upgraded` status value**), consume_posting exhaustion + expiry + multi-purchase ordering (**G-304: concurrency test — 1 remaining, two connections → exactly one succeeds**).
- **Task 1-1-03** (backend-engineer): `billing/mock.py` — deterministic mock provider (D-075): Stripe-shaped ids (`cs_mock_...` counter+randbits — the identity-mock unique-mint lesson), create/retrieve session (unpaid → paid on confirm), signature mint/verify identical scheme to real provider (whsec-shaped), normalize_event passthrough (mock emits canonical names directly). Tests: `tests/billing/test_mock.py`.
- **Task 1-1-04** (backend-engineer): `billing/webhooks.py` — verification core shared by providers: parse `Stripe-Signature` (t=,v1= pairs, multi-v1, ignore v0/unknown — R-38), HMAC-SHA256 over `{t}.{raw_body}` bytes, hmac.compare_digest, ±300s window with `now` injection (R-40, risk-4). Tests: `tests/billing/test_webhooks.py` — good/bad signature, stale timestamp (outside window), missing header, multi-v1 (secret roll), v0-scheme rejected, replay-same-event accepted (idempotent at the verify layer).
- **Task 1-1-05** (backend-engineer): `billing/factory.py` + config.py trio (R-53): `payment_provider="mock"`, `payment_api_key: SecretStr`, `webhook_secret=""` (per-boot random); UnknownPaymentProviderError; .env.example additions (commented, no real values). Tests: factory selection + unknown error + secret redaction (repr must not leak).
- **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: Foundation must-haves
### Wave 1-2: main.py wiring + deps + conftest (backend-engineer)
- P1 runs the full suite (fast lane) green: new tests + existing 670 unaffected (billing module is additive; main.py untouched this phase).
- mypy + ruff green (billing files join [tool.mypy] files set implicitly — same package).
- **Must-have:** mock provider + store + webhooks.py are usable WITHOUT any route — proven by direct-instantiation tests only.
- **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)**.
## Phase 2: Catalog + Pricing Surface (REQ-9-002)
### Wave 1-3: P1 security review + phase gate (security-auditor + lead-developer)
**Goal:** The catalog is real and surfaced: seeded packages/plans in the DB, `/v1/billing/catalog` public GET, the pricing page reads it at runtime, wire types pinned both sides.
- **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.
### Wave 2-1: Backend catalog route (backend-engineer)
## Phase 2: Application State Machine (REQ-11-002)
- **Task 2-1-01** (backend-engineer): `billing/catalog.py` — the seed constant (single $49/1 post/30d, bundle $399/10 posts/90d, enterprise custom; starter $99/mo, pro $299/mo, enterprise-talent custom — REQ-017's tier structure) + idempotent seed into store. Mounted via `api/billing.py`: `GET /v1/billing/catalog` → `{packages: [...], plans: [...]}` (public — catalog is advertising, not secret; CORS-existing policy applies). Deps: `get_billing_store` (R-51/R-52 wiring: deps.py + main.py lifespan + close in finally). Tests: `tests/billing/test_api.py` — catalog returns the 6 seeded items, shape pinned, unauthenticated 200.
**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-2: Web pricing conversion (frontend-engineer)
### Wave 2-1: Employer application routes + G-319 pin split (backend-engineer)
- **Task 2-2-01** (frontend-engineer): `packages/types/billing.ts` — Package/Plan/CatalogResponse types (cents documented, D-081); dual-schema pins in `apps/web/tests/pins.test.ts` (G-108 discipline: field-for-field vs base.py).
- **Task 2-2-02** (frontend-engineer): `engine-client.ts` — `listBillingCatalog()` via jsonFetch; page stays server shell (metadata + pins untouched — A-411); new `components/marketplace/pricing-client.tsx` ('use client', AbortController, loading/error/empty states — board-client pattern); CTA copy per D-079 (contact path + operator-mediated note; no fake purchase buttons). Tests: fetch-stub catalog render, error state render, empty catalog render, cents formatting.
- **Task 2-2-03** (frontend-engineer): pins updated: pricing-page metadata pin retained; new catalog wire pins added.
- **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.
## Phase 3: Checkout + Webhook Flow (REQ-9-003)
### Wave 2-2: Learner withdraw + operator lane incl. the R-80 re-hire (backend-engineer)
**Goal:** The full payment flow: admin creates a checkout session (mock by default, stripe via env), the provider completes it, the webhook (HMAC-verified, deduped, idempotent) grants the entitlement — proven E2E in tests with zero cloud calls.
- **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 3-1: Stripe provider (backend-engineer)
### Wave 2-3: P2 security review + phase gate (security-auditor + lead-developer)
- **Task 3-1-01** (backend-engineer): `billing/stripe.py` — form-encoded REST via the shared httpx pool: create session (mode, line_items[0][price_data] from catalog item snapshot, client_reference_id, Idempotency-Key uuid4 — R-33/R-35/R-49), retrieve session, cancel_subscription (DELETE or cancel_at_period_end — R-46), update_subscription (items[0][id] resolution inside provider — R-47 dual-charge guard), verify_webhook (shared webhooks.py), normalize_event (R-43 mapping). Tests: `tests/billing/test_stripe_provider.py` — MockTransport byte-contract: exact form-encoded body, Basic auth header, Idempotency-Key present, item-id in update body, key redaction in exceptions, normalize_event mapping table (Stripe → canonical).
- **Task 3-1-02** (backend-engineer): main.py factory wiring: provider from settings (mock default), G-11 loud fallback on factory error (voice precedent), provider on app.state + deps.
- **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.
### Wave 3-2: Checkout + webhook routes (backend-engineer)
## Phase 3: Placement Reporting (REQ-11-003)
- **Task 3-2-01** (backend-engineer): `api/billing_admin.py` — `POST /v1/billing/admin/checkout` (require_admin verbatim — R-51): body {item_id, employer_id} → catalog item lookup → create session (operator_ref + employer_id recorded, D-076; **success_url/cancel_url default from public_base_url per G-310: success = {public_base_url}/marketplace/pricing?checkout=success, cancel = …?checkout=cancelled; loud G-11 boot warning when stripe + public_base_url unset**) → persist checkout_sessions row → return {session_id, url}. Tests: fail-closed 403 (no/missing token), 404 unknown item, 201 with persisted row, **byte-contract: success_url + cancel_url present in the form body**.
- **Task 3-2-02** (backend-engineer): `api/billing.py` webhook — `POST /v1/billing/webhook`: `await request.body()` FIRST (risk-1 raw-body discipline), verify HMAC (401 on bad/missing signature), parse event, insert_provider_event (dedupe: existing → 200 already-processed), process by canonical name — **G-302: event payloads are untrusted ID references — the session/subscription resolves from LOCAL rows (creatable only via the admin route); unknown target id → recorded as an unmatched billing_event, NO entitlement, 200; purchase price/name from the stored snapshot, never the event body** — checkout.completed → verify predicate status=complete AND payment_status=paid (R-37) against the local session row → create purchase/subscription from session snapshot; invoice.paid → extend/renew; invoice.payment_failed → past_due; subscription.cancelled → cancelled. All transitions via store (InvalidTransition tolerated-as-duplicate, recorded; **G-306: unknown-target events never 500 — recorded unmatched, 200**). 200 fast. Tests: `tests/billing/test_webhook_route.py` — unsigned 401, bad sig 401, valid + duplicate (idempotent 200, single entitlement), out-of-order tolerated, grace window pinned, mock flow E2E, **G-302: signed event for unknown session → 200 + zero purchases + one unmatched row; inflated payload amount → price = snapshot**, **G-303: distinct event ids same session → one purchase**.
- **Task 3-2-03** (backend-engineer): mock-complete path (D-082, **amended by G-301**): `POST /v1/billing/mock/complete` mounted ONLY under provider=mock **AND composed with require_admin — the operator completing a session after out-of-band payment is the honest pilot flow (G-301: mock is the default provider, so this route exists on every default-config deployment; an unauthenticated completion route on a payment surface is a hole regardless of id unguessability)** — mints a signed event through the REAL webhook processing (direct handler call, same code path) — E2E proof: admin checkout → admin-gated mock complete → webhook → entitlement exists. Route 404s under stripe (factory-conditional mount, test-pinned). Tests: **no token → 403 under mock (G-301)**; 404 under stripe; E2E green.
**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).
## Phase 4: Entitlement Enforcement (REQ-9-004)
### Wave 3-1: Reporting reads (backend-engineer)
**Goal:** Posting creation enforces packages honestly; the admin surface shows entitlement state.
- **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 4-1: Enforcement composition (backend-engineer)
### Wave 3-2: Wire types + engine-client placements section (frontend-engineer + data-engineer)
- **Task 4-1-01** (backend-engineer): `api/marketplace_admin.py` create_posting composes billing AFTER require_admin: consume_posting(employer_id) → success: proceed (posts_used incremented); `PackageExhausted`/`NoActivePackage` (billing domain errors) → `HTTPException(402, detail={reason, posts_used, posts_limit, upgrade_cta: {catalog_hint}})` (dict-detail precedent — require_verified_age). Backward-compat: employers with UNLIMITED manual grants bypass (enterprise — D-080). **G-309: the enforcement exists ONLY in the admin route — run_ingest and any future adapter bypass consume_posting by design (seed ingestion is never entitlement-gated)**. Tests: 402 on no package, 402 on exhausted, 201 + consumption on valid, unlimited bypass, expired package degrades to 402, **seeded ingest on a zero-purchase store → all postings upserted, zero 402s (G-309)**.
- **Task 4-1-02** (backend-engineer): `GET /v1/billing/admin/entitlements/{employer_id}` — active package, posts used/limit, expiry, subscription state (require_admin). Tests: shape + fail-closed.
- **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 4-2: Admin surface (frontend-engineer)
### Wave 3-3: Employer web surfaces (frontend-engineer)
- **Task 4-2-01** (frontend-engineer): admin moderation surface gains per-employer entitlement panel (**G-308 design, replacing the A-406 citation — the entitlements route is admin-gated, A-406 is the public-route pattern**): a small operator-token input persisted to sessionStorage + a new `adminFetch` wrapper in engine-client (X-Admin-Token header, token never logged); 403 renders an honest locked state with the token prompt; panel shows active package, posts used/limit bar, expiry, subscription state badge, manual-grant pointer. Tests: fetch-stub render, 402-CTA payload rendering, **stubbed 403 → locked-state render with prompt (G-308)**, **fetch stub asserts the X-Admin-Token header**, no admin-token string in non-test web source beyond the wrapper.
- **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.
## Phase 5: Subscription Lifecycle (REQ-9-005)
### Wave 3-4: P3 phase gate (lead-developer)
**Goal:** Full lifecycle: state machine driven by webhooks + manual admin actions, audit log complete, past_due grace honest.
- **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.
### Wave 5-1: Lifecycle + admin routes (backend-engineer + data-engineer)
## Phase 4: Audit Export (REQ-11-004)
- **Task 5-1-01** (data-engineer): store transition matrix complete: trialing→active, active→past_due, past_due→active (recovery), past_due→expired (**G-306: lazily computed from past_due_at + 7-day constant at read/entitlement time — no background timer; test-pinned D-083**), active→cancelled, cancelled terminal; **G-305: no `upgraded` status — upgrade is a plan_id transition within `active`, recorded as a billing_event with provenance**; expires_at extension idempotent on invoice.paid; manual grant/revoke/complement methods (source="manual" + operator provenance in billing_events). Tests: full matrix + grace-elapsed expiry (**injected now: past_due_at+6d → granted, +8d → expired — no sleeps**) + manual-action audit rows + **upgrade keeps status=active, changes plan_id, writes billing_event (G-305)**.
- **Task 5-1-02** (backend-engineer): `api/billing_admin.py` lifecycle routes: POST admin/subscriptions/{id}/cancel, /resume, /upgrade (provider calls for stripe; state transitions for mock), POST admin/employers/{id}/grant (manual unlimited/package), /revoke, GET /v1/billing/admin/events (queryable audit log: by employer, by type, by time). Tests: each route happy path + fail-closed 403 + InvalidTransition 409.
- **Task 5-1-03** (backend-engineer): webhook subscription.renewed handling: renewal invoice.paid extends expires_at idempotently (event dedupe makes double-fire safe — R-45); corroborating customer.subscription.updated recorded. Tests: double-renew single-extension.
**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.
## Phase 6: Final Review + Ship (milestone release, v0.8.x line)
### Wave 4-1: Export + summary routes + methodology + E2E (backend-engineer)
**Goal:** Multi-persona review of all v0.9 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.8.6 (final v0.8.x patch = milestone release), full asset matrix + registry image via the release lane (D-064), milestone completion commits, branch cleanup.
- **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.
- Review: P0 fixes in-phase; P1+ fixed here, never loop back to EXECUTE.
- Audit: git log ↔ .ciagent/ reconstruction; file discipline; branch hygiene; commit discipline (feat: scoped to billing + pricing/admin surfaces).
- Ship: phase/(N+1) branch → milestone → main; delete all milestone branches (tags preserve history).
- Complete: REQUIREMENTS.md (REQ-9-001..005 complete), ROADMAP.md milestone entry, checkpoint cleared.
### 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.
---
## Must-Haves (per phase — concrete, falsifiable, test-linked)
- **P1 (REQ-9-001):** billing module importable + tested standalone; store tests green (7 tables, dedupe, transitions); webhooks.py verify matrix green; factory + settings trio live; fast suite green; NO routes mounted, NO web changes. **MH-1a** sqlite_master shows 7 billing tables. **MH-1b** duplicate insert_provider_event returns created=False. **MH-1c** bad-signature verify raises. **MH-1d** factory: mock default, unknown raises, key redacted.
- **P2 (REQ-9-002):** catalog route serves 6 seeded items; pricing page renders from runtime fetch with honest states; dual-schema pins green; metadata pin retained. **MH-2a** GET /v1/billing/catalog unauth 200 with exactly the seeded items. **MH-2b** pins.test.ts billing section green. **MH-2c** pricing page: no inline JOB_PLANS/TALENT_PLANS constants remain (grep-clean).
- **P3 (REQ-9-003):** stripe provider byte-contract green under MockTransport; webhook 401/200/idempotent matrix green; mock E2E green (checkout → complete → entitlement). **MH-3a** unsigned webhook → 401, never processed. **MH-3b** duplicate event → 200, single entitlement; **distinct event ids, same session → one purchase (G-303)**. **MH-3c** E2E: purchase exists after admin-gated mock complete with predicate honored; **no-token complete → 403 (G-301)**. **MH-3d** update body contains items[0][id]. **MH-3e** no test touches the network (MockTransport-only, grep-pin). **MH-3f** signed event for unknown session → 200, zero entitlement, unmatched row (G-302).
- **P4 (REQ-9-004):** create_posting enforces; 402 payload shape pinned; admin entitlement view renders. **MH-4a** no-package posting → 402 with upgrade_cta. **MH-4b** exhausted → 402; valid → 201 + posts_used incremented. **MH-4c** unlimited bypass. **MH-4d** expired degrades to 402.
- **P5 (REQ-9-005):** transition matrix complete + grace pinned; manual actions audited; renewal idempotent; event log queryable. **MH-5a** grace-elapsed past_due → expired (7-day constant pinned, lazy evaluation per G-306 — no sleeps); **no `upgraded` status value exists (G-305)**. **MH-5b** double-renew single extension. **MH-5c** manual grant → billing_events row with source=manual. **MH-5d** GET admin/events filters by employer.
## Milestone Must-Haves
- REQ-9-001..005 all complete; the mock payment loop is the machine-checked E2E proof (checkout → webhook → entitlement → consumption → renewal/cancel).
- The stripe provider is byte-contract-proven without ever leaving MockTransport.
- Zero secrets in code/tests/examples; AI_WEBHOOK_SECRET unset → webhooks fail closed.
- Suite counts: pytest + web + cli all green, zero warnings (v0.7 regime holds).
- Docs: .env.example billing section; deploy/README.md unaffected (billing needs no admin-box changes — record this explicitly); REQUIREMENTS/ROADMAP updated.
- 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
1. **Raw-body discipline** (risk-1): any body pre-parse breaks HMAC — pinned by MH-3a + route-order test.
2. **Dual-charge** (R-47): pinned by MH-3d.
3. **Out-of-order events** (R-45): transitions tolerate duplicates; InvalidTransition recorded not raised for already-reflected state — pinned in webhook tests.
4. **Test clock**: now-injection in verify + store (no sleeps — deterministic).
5. **Backward compat at P4**: existing marketplace admin tests create postings for seeded employers — seed catalog + grant manual unlimited to test employers OR mark route change explicit in fixtures (decided: tests grant unlimited to legacy seeded employers — honest, explicit).
6. **Pins churn**: pins.test.ts grows — keep the G-108 discipline (regression/boundary pins only, no local-grep copy pins).
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. **Pricing page** — real catalog data at runtime (packages + plans with cents-true prices), honest loading/error states, contact-path CTAs (operator-mediated checkout note).
2. **Admin billing surface** — per-employer entitlement panel (package, posts used/limit, expiry, subscription badge) in the moderation view; operator checkout trigger.
3. **Public API** — GET /v1/billing/catalog (advertises the catalog); POST /v1/billing/webhook (provider-facing).
4. **Operator API** — /v1/billing/admin/* (checkout, entitlements, lifecycle, grants, event log) behind the admin token.
5. **Release notes** — v0.8.x line ships the billing surface through the existing automated lane (no admin-box changes required).
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. **Operator buys for an employer (mock pilot):** admin token → POST /v1/billing/admin/checkout {item: bundle, employer: E1} → session created → mock complete → webhook (signed, deduped) → purchase row: 10 posts, $3.99 snapshot → E1's admin panel shows 10/10 posts available.
2. **Employer posts jobs:** create_posting E1 → 201, posts_used 1..N; at 10/10 the 11th → 402 with upgrade_cta → operator buys enterprise grant → unlimited.
3. **Subscription month rolls:** pro plan renews → invoice.paid webhook → expires_at extended exactly once (double-delivery safe) → talent-access stays active.
4. **Payment fails:** card declines → invoice.payment_failed → past_due → 7-day grace → still unpaid → expired (honest downgrade) — OR recovery payment inside grace → active again.
5. **Stripe switch (env-only):** AI_PAYMENT_PROVIDER=stripe + keys → same routes, real hosted checkout, real webhooks — zero code changes.
6. **Visitor checks pricing:** /marketplace/pricing loads → catalog fetched → prices + tiers match the DB exactly.
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. No dishonest buttons: every CTA states the real pilot path (operator-mediated); no fake "buy now" without a real flow behind it.
2. Prices are always cents-true and consistent: pricing page = catalog API = purchase snapshots; no drift.
3. Failures are loud and actionable: 402 payloads carry upgrade_cta; webhook failures fail closed (401) rather than processing unsigned; past_due is visible in the admin panel before downgrade.
4. The admin never needs to guess: entitlement panel answers "can this employer post?" in one glance; the event log answers "what happened to this subscription?" with full provenance.
5. Existing surfaces unchanged: job board, defense, sandboxes, deploy — billing is additive; any regression there is a P0.
---
# v0.8 PLAN (Ship Anywhere — prior milestone, retained below)
## Overview
**Milestone:** v0.8 — Ship Anywhere (Feature): nextcraft installable on any Debian-based Linux server via every mainstream channel — docker image from the Gitea registry, .deb packages, one-liner binary (x64 + arm64) — with release consistency enforced by pre-commit hooks + Gitea Actions pipelines, and the CLI carrying the full suite for zero → running onboarding. Admin guides document all of it.
**Tag line:** phase tags advance the v0.7.x line (P0 → v0.7.0 … P6 → v0.7.6 = milestone release), continuing from the v0.6.6 milestone release
**Branch:** milestone/v0.8-ship-anywhere
**Phase list (requirement mapping):**
| Phase | Requirement | Focus |
|-------|-------------|-------|
| P1 | REQ-8-001 | Release-consistency rails: runner fleet on CT 113, push/tag workflows, pre-commit, hello-world verification |
| P2 | REQ-8-002 | Docker image: multi-stage build, unprivileged runtime + honest sandbox degrade, registry push on every release |
| P3 | REQ-8-003 | Debian-native: arm64 cross-build, .deb packages (x64+arm64), install.sh arch-resolve, release assets extended |
| P4 | REQ-8-004 | CLI onboarding: doctor channel awareness, `nextcraft guide`, in-container refusals, per-channel E2E |
| P5 | REQ-8-005 | Admin guides: the full deploy runbook (three channels × install/upgrade), README quickstart |
| P6 | — | Final review + audit + milestone ship (v0.7.6 = milestone release) |
**Wave order (binding): rails before artifacts before onboarding before guides.** P1 builds the rails everything else ships through — P2/P3 extend the release workflow P1 creates; P4 onboarding needs all artifact channels to exist; P5 guides document the final shape. Within P1, the runner fleet gates the workflow verification (workflows are inert config until a runner registers). P2 precedes P3 for founder priority (docker is the headline channel), not technical force — both consume P1's release workflow as their extension point (G-202).
**Research decisions R-1..R-32 (RESEARCH.md) and clarify decisions D-063..D-072 (PROJECT.md) are binding design contracts.** This plan operationalizes them; it does not re-litigate them.
**Environment facts (probe-verified):** Gitea 1.26.1 at git.coreci.dev; OCI registry `/v2/` live (401 unauth); Actions enabled, zero runs to date; CT 113 gitea-runner LXC (10.0.0.113, 4c/4GB/32GB, root SSH, founder-provided credentials — never committed); registration token founder-provided in chat, reset after fleet green; latest release v0.6.6 (id 909) carries nextcraft-linux-x64 + .sha256; runner image is `docker.io/gitea/runner:3` (act_runner renamed, R-1); jobs run as siblings via host docker.sock; v0.7 toolchain (eslint flat config, ruff + RUF100, mypy relaxed-first, pytest markers fast/real_server/userns with `ai:test:fast`, c8, turbo pipeline) is the CI surface.
**Secrets policy (binding, v1.8 lesson):** tokens never in shell env, never committed, never echoed. Registration token = compose env at bring-up only (R-6). REGISTRY_TOKEN = Gitea Actions secret (self-serve `PUT /repos/coreci/nextcraft/actions/secrets/REGISTRY_TOKEN` attempt with the `.ciagent/.env.secrets` GITEA_TOKEN first; founder UI fallback — R-13). The automatic job `GITEA_TOKEN` cannot publish packages (R-13, upstream-confirmed). CT 113 root credentials: use once at P1 bring-up, install repo-owned access thereafter, founder rotates the password.
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to the four channels + guides.
---
## Phase 1: Release-Consistency Rails (REQ-8-001)
**Goal:** Three org-level runner containers green on CT 113; push-lane workflows (node/python lanes per D-063) and a tag-triggered release workflow live; pre-commit hooks enforce local hygiene; the fleet proven by hello-world dispatch across all three labels.
### Wave 1-1: Runner fleet bring-up on CT 113 (devops-engineer)
- **Task 1-1-01** (devops-engineer): CT 113 bring-up probes — SSH to root@10.0.0.113 (founder creds, use once), verify docker daemon (`docker info`), LXC nesting health (cgroup/ns errors), egress (`curl -fsSL https://git.coreci.dev/api/v1/version` → 200; docker.io reachability for `gitea/runner:3` + job images; github.com reachability for actions/checkout per R-8 — record verdict, it selects the checkout strategy). Install an ed25519 deploy key for subsequent phases; founder rotates root password after this task — **rotation is a Wave 1-1 completion condition (G-206): if unconfirmed by Wave 1-2, commit `escalation: ct_root_password_still_live` and carry it in the ship notes — never silently.**
- **Task 1-1-01b** (devops-engineer): **Fleet-as-code (G-207)** — the compose file, per-runner config.yaml snippets, and the prune cron land in-repo under `deploy/ci/` (compose with `${REGISTRATION_TOKEN}` env interpolation, configs with `${CT_IP}` documented as bring-up variables); CT 113 deployment = copy + `docker compose up -d`. The runbook's fleet recipe references these committed files, never box memory.
- **Task 1-1-02** (devops-engineer): Fleet deployment per R-1..R-5 — `/srv/nc-ci/docker-compose.yaml` with three services (nc-runner-node / nc-runner-python / nc-runner-release), image `docker.io/gitea/runner:3`, per-runner `./data-runner-*` volumes (never shared — lock refusal), host `/var/run/docker.sock` mount, `GITEA_INSTANCE_URL=https://git.coreci.dev`, `GITEA_RUNNER_NAME`, `GITEA_RUNNER_LABELS` per the R-3 hybrid map (node → node:24-bookworm; python → python:3.11-bookworm; release → node:24-bookworm; ubuntu-latest → docker.gitea.com/runner-images:ubuntu-latest, carried by runner-node), registration token via compose env (from founder chat, never a file). Per-runner config.yaml with `capacity: 1`, `cache.enabled/host: 10.0.0.113/port` published per runner (R-5 — else actions/cache ETIMEDOUTs), `health_check.min_free_disk_space_mb: 4096`. systemd unit for the compose stack (`restart: always`, reboot-persistent — house pattern).
- **Task 1-1-03** (devops-engineer): Fleet verification — all three runners visible + idle at org level (`/org/coreci/settings/actions/runners`); registration token reset by founder after this task (ask; do not hold the pipeline — the token's job is done once three `.runner` files exist); host-side cache-hygiene cron (daily `docker system prune -af --filter until=168h` + `docker volume prune -f`).
### Wave 1-2: Workflows + secrets + hello-world (devops-engineer)
- **Task 1-2-01** (devops-engineer): `.gitea/workflows/ci.yaml` — `on: push` (all branches), two jobs: node lane (`runs-on: node`: corepack pnpm 12.3.4 — probe corepack-on-node24, `npm i -g pnpm@12.3.4` fallback per R-11 — `pnpm install --frozen-lockfile`, lint + typecheck via `pnpm --filter` over the 5 TS workspaces (never wholesale `turbo lint` per R-8), `pnpm web:test`, `pnpm cli:test` with E2E env-gated out per D-063) and python lane (`runs-on: python`: bootstrap.sh → lint.sh → typecheck.sh → `FAST=1 test.sh`). checkout via `actions/checkout@v4` if Task 1-1-01 found github.com egress; else raw `git fetch` steps with the job token (R-8). pnpm store + pip cache via `actions/cache@v4` (R-12; volume-mount fallback if cache server unreachable).
- **Task 1-2-02** (devops-engineer): `.gitea/workflows/release.yaml` — `on: push: tags: ['v*']`, `runs-on: release`: build the x64 SEA binary stamped `${{ gitea.ref_name }}` (`pnpm --filter @nextcraft/cli build:binary $TAG`), sha256 sidecar, attach both to the tag's release via the REST API (`scripts/release-assets.sh` mechanics per R-14 — call the API directly in the workflow step; the script itself stays untouched as the manual fallback with its .env-only 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 the workflow build + attach green (draft/pre-release or immediately-deleted release), then delete the tag — the phase ship tag remains the real artifact.
- **Task 1-2-03** (devops-engineer): `.gitea/workflows/hello.yaml` — `workflow_dispatch` one-step echo job × 3 (`runs-on: node|python|ubuntu-latest`) — the REQ-8-001 fleet proof. Dispatch all three, observe green in the Actions tab.
- **Task 1-2-04** (devops-engineer): REGISTRY_TOKEN setup per G-205 — the secret is a **founder-minted PAT with package-write scope** (write:package + write:repository); first attempt the self-serve PUT (`PUT /repos/coreci/nextcraft/actions/secrets/REGISTRY_TOKEN` with the `.ciagent/.env.secrets` token, R-13); if the token's scope cannot mint it, escalate to the founder with the exact scope needed — a founder question, never a silent wait. P2's registry push blocks on this secret existing; P1 does not.
- **Task 1-2-05** (devops-engineer): `.pre-commit-config.yaml` at repo root (D-070): directory-scoped hooks — ruff (ai-service globs), eslint flat-config run (web/cli/packages globs), tsc `--noEmit` per TS workspace, basic hygiene (trailing whitespace, merge conflicts, large files). Local-only, no network, fail-loud on missing toolchain (per D-070 — CI backstops). Document the opt-in install AND the no-toolbox skip path in the README dev section (G-209 — a contributor without ruff gets a loud pointer, not a broken commit flow).
### Wave 1-3: P1 verification (lead-developer + security-auditor)
- **Task 1-3-01** (security-auditor): Secrets audit of P1 — no token/credential in any committed file (compose, workflows, pre-commit, docs); registration token lifecycle honored (brought up via env, reset requested); CT root password absent from repo + rotated by founder; workflow env scrubbed (no `GITEA_TOKEN` echo steps).
- **Task 1-2-05 → verification hook** (lead-developer): push-lane proof — a deliberate commit touching both lanes lands green runs in the Actions tab; hello-world dispatch green × 3 labels; pre-commit blocks a deliberately broken lint file and passes a clean tree (both directions proven locally).
**Verification hooks P1:** ci.yaml green on a fresh push (both lanes); hello.yaml green × 3 labels; release.yaml exercised at P1 ship (the v0.7.1 tag's release carries binary + checksum, built by the workflow — not by the local ship path); pre-commit both-direction proof; secrets audit clean; runners idle-registered at org level.
---
## Phase 2: Docker Image Channel (REQ-8-002)
**Goal:** The full stack as one unprivileged container — deps pre-loaded, single-port topology preserved, honest sandbox degrade, pushed to the Gitea registry on every phase release.
### Wave 2-1: Image + degrade (devops-engineer + sandbox-engineer)
- **Task 2-1-01** (devops-engineer): Multi-stage Dockerfile per R-16..R-19 — web-export stage (node:24-bookworm, corepack pnpm, `NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build`, outputs `apps/web/out`), venv stage (python:3.11-bookworm, bootstrap.sh mechanics), runtime stage (python:3.11-slim-bookworm, non-root `nextcraft` user, uvicorn :8420, `AI_WEB_STATIC_DIR` → export, `NC_CONTAINER=1` marker, `EXPOSE 8420`, `HEALTHCHECK` GET /health, state volume `/home/nextcraft/.nextcraft`). CLI embedded per D-066 (`doctor`/`verify`/`guide` usable in-container; image tag stamped `NEXTCRAFT_VERSION_STAMP`).
- **Task 2-1-02** (sandbox-engineer): Honest degrade per D-068/R-27..R-29 — `ai_service/sandbox/capability.py` promoting `tests/helpers/userns.py::userns_available()` verbatim (real probe, truth source: attempt userns + report), boot notice at startup when degraded (main.py G-11 loud-warning pattern), 503 arm beside PoolFullError in sandboxes.py (env creation rejected with remediation payload; web surfaces already render honest errors — additive only). **Remediation text is a single tested constant (G-208):** names the unprivileged default, the seccomp constraint, the documented privileged/seccomp-override variant (exact docker run flags), and the host-side alternative — the boot notice and the 503 payload carry the same constant. Tests: degraded-mode unit tests (probe mocked false → 503 + notice asserted); real userns path unchanged.
- **Task 2-1-03** (cli-engineer): In-container CLI refusals per D-066/R-30 — `bootstrap`/`deploy`/`dev` detect `NC_CONTAINER` (or `.dockerenv` + marker) and refuse with honest exit 1 + remediation text (image is immutable; upgrade = docker pull per D-071); `doctor` gains container-mode branch (skips repo-root checks, verifies mount + /health); `verify` container semantics (probe the live listener + export presence).
### Wave 2-2: Registry push + release integration (devops-engineer)
- **Task 2-2-01** (devops-engineer): Release workflow registry step per R-20/D-065 — `docker build` on the tag ref, `docker login git.coreci.dev` with `secrets.REGISTRY_TOKEN`, push `git.coreci.dev/coreci/nextcraft:$TAG` + `:latest`. Blocks on Task 1-2-04's secret existing (escalation if still pending: ship proceeds, push step marked `continue-on-error: false` + release notes note the missing image).
- **Task 2-2-02** (devops-engineer): `docker run` smoke gate in the workflow — five pinned assertions (G-204): container starts → `/health` 200 within timeout → `GET /` is content-type text/html AND contains a known page marker (not merely non-JSON-404) → degrade boot notice present in container logs → fabric env-create returns 503 with the remediation payload → clean stop. A broken image = failed release job.
- **Task 2-2-03** (security-auditor): Image audit — non-root confirmed, no secrets in layers (`.env*`/`.ciagent/` excluded via `.dockerignore`), healthcheck present, minimal runtime (no build toolchain in the final layer).
**Verification hooks P2:** image builds locally + on the runner; pushed to the registry under the phase tag + latest; smoke gate green (HTML at /, degraded fabric reported honestly); degrade tests green in the fast pytest lane; in-container doctor/verify/refusals green (CLI tests); `.dockerignore` excludes secrets; all existing suites stay green (no regressions from capability.py promotion — the real userns suites unchanged).
---
## Phase 3: Debian-Native Channel (REQ-8-003)
**Goal:** arm64 binaries + .deb packages for x64 + arm64, attached to every release; install.sh resolves arch; every channel ends at doctor/verify green.
### Wave 3-1: arm64 cross-build (cli-engineer)
- **Task 3-1-01** (cli-engineer): `build-binary.mjs` arch parameter per R-25 — `--arch x64|arm64`; the ONLY arch-specific step (copy of `process.execPath`) replaced by the official arm64 node tarball download (probe-verified 200/30MB) for arm64; esbuild bundle + postject injection unchanged (arch-agnostic file surgery); output `nextcraft-linux-arm64` + `.sha256`. **Decided fallback ladder (G-201):** probe QEMU-free cross-arch SEA on the runner first; if the blob fails under emulation, fall back to binfmt+qemu for FULL arm64 builds; if the runner cannot install binfmt, ship v0.8 x64-only (arm64 deferral recorded in ship notes + REQUIREMENTS) — never leave the release lane red or blocked.
- **Task 3-1-02** (cli-engineer): Binary contract tests — arm64 binary `--version` reports the stamped tag (under emulation in CI); x64 path byte-identical to the v0.4 pipeline (checksum + version gates unchanged).
### Wave 3-2: deb packages + installer + release extension (cli-engineer + devops-engineer)
- **Task 3-2-01** (cli-engineer): `scripts/build-deb.sh` per R-22/R-23 — `dpkg-deb --root-owner-group -Zxz`, package `nextcraft` Version = tag minus `v`, empty Depends + `Recommends: nodejs, python3, git, util-linux`, contents: `/usr/bin/nextcraft` (arch-matched binary) + `/usr/share/doc/nextcraft/` (README + guide); postinst = advisory doctor (`|| echo` → always exit 0, never fails dpkg per D-067); outputs `nextcraft_<ver>_<arch>.deb` + sidecars.
- **Task 3-2-02** (cli-engineer): `install.sh` arch-resolve per R-24 — `uname -m` x86_64 → x64 asset, aarch64/arm64 → arm64 asset; checksum + version integrity gates byte-identical for both; degradation messaging unchanged.
- **Task 3-2-03** (devops-engineer): Release workflow extension — arm64 binary build + both debs join the release-asset attach step (x64 binary, arm64 binary, 2× sha256, 2× deb — full asset matrix per D-064); smoke: deb installs in a fresh debian container, `nextcraft --version` + advisory doctor run. **Proof via the G-203 temporary-rc-tag mechanism.**
- **Task 3-2-04** (security-auditor): Asset audit — all 6 release assets checksum-verified, version stamps match the tag, no secrets in debs.
**Verification hooks P3:** arm64 binary + 2 debs build in CI (release lane green on the phase tag); install.sh fetches the right asset per arch (fixture-server test extended, tamper-rejection unchanged); deb installs → binary on PATH → advisory doctor runs; x64 pipeline byte-identical (no regression); asset matrix complete on the v0.7.3 release.
---
## Phase 4: CLI Onboarding (REQ-8-004)
**Goal:** `nextcraft` alone takes an admin zero → running — channel-aware doctor, `guide`, honest refusals, per-channel E2E.
### Wave 4-1: Channel awareness + guide (cli-engineer)
- **Task 4-1-01** (cli-engineer): Doctor channel awareness per R-30 — container-mode branch (from P2), deb-install detection (`dpkg -s nextcraft` or binary-at-/usr/bin heuristics; report channel + its upgrade semantics per D-071), binary-install default. Each channel's doctor outputs the right next-step line (deploy --service vs docker pull + recreate).
- **Task 4-1-02** (cli-engineer): `nextcraft guide` per D-072 — offline-first embedded man-page text (no runtime fetches): sections per channel (first install, upgrade, troubleshooting pointers), forge URLs as pointers. `guide [topic]` optional arg (guide deploy / guide docker / guide deb / guide troubleshooting).
- **Task 4-1-03** (cli-engineer): CLI tests for the new surface — dispatch tests (guide routing, refusals, doctor branches per detected channel via env/fixtures); exit-code contract unchanged (0 ok / 1 failure / 2 usage).
### Wave 4-2: Per-channel E2E (cli-engineer + devops-engineer)
- **Task 4-2-01** (cli-engineer): Docker-channel E2E (env-gated like the v0.4 E2E): pull the released image, run, in-container doctor/verify green, refusals honest, teardown. Wired as `cli:test:e2e:docker`, excluded from default lanes.
- **Task 4-2-02** (cli-engineer): deb-channel E2E (env-gated): fresh debian container, install the released deb, advisory doctor, `nextcraft guide`, teardown. `cli:test:e2e:deb`.
- **Task 4-2-03** (lead-developer): The binary channel E2E already exists (fresh-clone, v0.4) — verify it still passes with the arch-resolve installer; wire the three E2E names into the docs so admins can reproduce them.
**Verification hooks P4:** all CLI suites green at current counts; guide renders offline (no network mock asserted); doctor reports the right channel in all three modes (fixture tests); docker + deb E2E green when env-gated on; existing binary E2E green; no regression in the 61-test CLI baseline surface (net new tests allowed).
---
## Phase 5: Admin Guides (REQ-8-005)
**Goal:** The admin runbook — every channel's first-install + upgrade recipe, evidence-backed, one entry point.
### Wave 5-1: The runbook (lead-developer + cli-engineer)
- **Task 5-1-01** (lead-developer): `deploy/README.md` → the full admin runbook per R-31/R-32 — structure: pick-your-channel matrix → docker channel (docker run, volumes, privileged variant for full in-container sandboxes, upgrade = pull + recreate per D-071) → deb channel (dpkg -i, advisory doctor, deploy --service integration) → binary channel (install.sh one-liner, existing recipe preserved) → upgrade matrix table → HAProxy/systemd recipes (existing content preserved) → troubleshooting tables (runner/registry/docker-degrade/deb — extended from the CLI README's) → evidence appendix (which test or live probe backs each recipe).
- **Task 5-1-02** (cli-engineer): `nextcraft guide` content parity — the runbook's channel sections are the canonical text, extracted to a single shared source (G-210); the guide renders from that source; divergence = parity test failure (never doc drift).
- **Task 5-1-03** (lead-developer): README quickstart updated — three channels listed with one command each; the v0.8 milestone line added to the history table.
### Wave 5-2: Guide verification (lead-developer)
- **Task 5-2-01** (lead-developer): Every runbook recipe evidence-checked — each recipe carries a pointer to its backing test (E2E name, workflow job, or live-box probe from prior phases); the parity test proves guide ↔ runbook agreement; doc lint (markdown structure) if cheap.
**Verification hooks P5:** runbook complete (all channels, upgrade matrix, troubleshooting, evidence appendix); parity test green; README quickstart accurate; no unevidenced recipe claims (each has a pointer); existing deploy recipes (systemd/HAProxy) preserved verbatim-accurate.
---
## Phase 6: Final Review + Ship (milestone release, v0.7.x line)
- **Task 6-1-01** (lead-developer → ci-review personas): multi-persona review of the full milestone branch diff (all phases — workflows correctness, Dockerfile hardening, deb packaging, CLI surface, docs accuracy); security-auditor confirms secrets policy end-to-end (no token in any layer/asset/workflow), image non-root, deb postinst advisory-only.
- **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.7.x phase tag = v0.7.6); Gitea release with full summary + the complete asset matrix (x64/arm64 binaries + checksums + 2 debs) + registry image tags; delete milestone branches; mark REQ-8-001..005 complete; clear checkpoint.
---
## Must-Haves (per phase — concrete, falsifiable, test-linked)
### Phase 1
- **MH-1a**: Three runners visible + idle at org level (`/org/coreci/settings/actions/runners`) — named nc-runner-node/python/release, hybrid labels declared per the R-3 map; registration token reset requested from founder after green fleet.
- **MH-1b**: `ci.yaml` green on a fresh push — node lane (lint+typecheck via per-workspace filters, web:test, cli:test) + python lane (bootstrap/lint/typecheck/FAST test) both observed in the Actions tab.
- **MH-1c**: `hello.yaml` dispatch green × 3 labels (node/python/ubuntu-latest) — the fleet proof.
- **MH-1d**: `release.yaml` exercised via the G-203 temporary rc tag on the phase branch — workflow observed building binary + checksum and attaching them green; the phase ship release (v0.7.1) carries the same machine-made assets.
- **MH-1e**: pre-commit proven both directions — blocks a deliberately broken lint file, passes a clean tree; config is local-only (no network hooks); the no-toolbox skip path is documented in README dev (G-209).
- **MH-1f**: Secrets audit clean — grep-verifiable: no token/credential in any committed file; CT root password used once, founder rotation requested as a Wave 1-1 completion condition and confirmed or escalated (G-206); workflows carry no shell-env token echoes.
### Phase 2
- **MH-2a**: Image builds (local + release lane) and pushes to `git.coreci.dev/coreci/nextcraft` under the phase tag + `latest`; `/v2/` manifest probe-verified after push.
- **MH-2b**: Smoke gate green with the five pinned assertions (G-204) — container start → /health 200 → `GET /` HTML with known marker → degrade notice in logs → 503 with remediation on env-create → clean stop; a broken image fails the release job.
- **MH-2c**: Degrade is honest — capability probe is a real userns attempt (not an env-var guess); degraded boot notice logged once with remediation text; env-create rejected 503 with remediation payload; real userns path unchanged (userns suites green locally).
- **MH-2d**: In-container CLI semantics — doctor/verify/guide work; bootstrap/deploy/dev refuse with remediation (exit 1); all pinned by tests.
- **MH-2e**: Image hygiene — non-root runtime, `.dockerignore` excludes `.env*` + `.ciagent/`, no build toolchain in the final layer (auditor-verified).
### Phase 3
- **MH-3a**: arm64 binary builds per the G-201 ladder — QEMU-free cross-arch SEA if the probe passes; binfmt+qemu full build if not; x64-only deferral (recorded) if binfmt is impossible — the ladder's outcome is recorded either way, `--version` reports the stamped tag under emulation, sha256 sidecar matches.
- **MH-3b**: Both debs build + install in fresh debian containers — `nextcraft --version` + advisory doctor run; postinst never fails dpkg (proven with missing-Recommends environment).
- **MH-3c**: install.sh arch-resolve — x86_64 → x64 asset, aarch64 → arm64 asset, checksum + version gates byte-identical; fixture-server tests extended to both arches.
- **MH-3d**: v0.7.3 release carries the complete asset matrix: 2 binaries + 2 checksums + 2 debs (6 assets), all verified — or the recorded G-201 deferral outcome (x64-only) with the arm64 rows explicitly marked deferred, never silently missing.
### Phase 4
- **MH-4a**: Doctor reports the detected channel (container/deb/binary) with the channel-correct next-step line, in fixture tests covering all three.
- **MH-4b**: `nextcraft guide` renders offline (no network calls — asserted), topics route (`guide docker|deb|troubleshooting|deploy`), exit-code contract intact.
- **MH-4c**: Refusals honest — in-container bootstrap/deploy/dev exit 1 with remediation text (pinned by tests).
- **MH-4d**: Both new E2Es (docker, deb) exist env-gated, green when run; the binary-channel E2E still green; guide ↔ runbook parity test green.
### Phase 5
- **MH-5a**: Runbook covers all three channels (first-install + upgrade each), the upgrade matrix, HAProxy/systemd recipes, and the four troubleshooting tables — every recipe carries an evidence pointer (test name / workflow job / live probe).
- **MH-5b**: Parity test green — `nextcraft guide` content and the runbook agree section-by-section.
- **MH-5c**: README quickstart lists all three channels accurately; milestone history updated.
### Phase 6
- **MH-6a**: Multi-persona review of the full diff clean (P0s fixed in-phase); secrets policy confirmed end-to-end by security-auditor.
- **MH-6b**: ci-audit reconstruction + discipline checks green.
- **MH-6c**: Milestone shipped — v0.7.6 tagged, merged to main, release carries the full asset matrix + image tags, REQ-8-001..005 complete, branches deleted, checkpoint cleared.
## Milestone Must-Haves
- **MH-M1**: All five requirements complete per their REQUIREMENTS.md rows (REQ-8-001..005).
- **MH-M2**: A push to any branch runs lint + typecheck + fast tests on the runner fleet, green (the release-consistency rail the founder asked for).
- **MH-M3**: Every phase release from v0.7.1 onward is machine-made the same way: tag push → workflow builds → binary + checksum (+ image from P2, + arm64/deb from P3) attached + registry pushed.
- **MH-M4**: Each channel takes a fresh Debian server to a running verified stack: docker run → /health + HTML; dpkg -i → doctor → deploy --service; install.sh → deploy --service (proven by E2E/workflow smoke or live-box evidence).
- **MH-M5**: The admin runbook exists as one entry point with per-channel recipes and upgrade paths, every recipe evidence-backed; `nextcraft guide` mirrors it offline.
- **MH-M6**: Zero silent failures: honest sandbox degrade (loud notice + 503 remediation), honest in-container refusals, honest channel reporting in doctor — the v0.5+ honesty pattern extended to distribution.
## Risks
- **CT 113 single point of failure** (fleet host) — mitigation: compose + config + cron committed as documentation-in-repo (the runbook reproduces the fleet from scratch); founder access path recorded.
- **Registration token expiry mid-P1** — mitigation: bring-up is Wave 1-1's first task; the token's job (3 registrations) completes before anything else depends on it; reset requested immediately after.
- **Cache-server reachability from job containers** (R-5) — mitigation: cache.host + published ports verified at bring-up; volume-mounted pnpm/pip stores are the deterministic fallback (R-12).
- **actions/checkout needs CT egress to github.com** (R-8) — mitigation: probe at 1-1-01; raw git fetch steps as the fallback lane.
- **SEA blob cross-arch behavior** (R-26) — mitigation: probe-first in P3; binfmt fallback scoped to blob generation.
- **Registry push auth failure** (REGISTRY_TOKEN 401/403 on self-serve) — mitigation: founder UI fallback (escalation commits, P2 marks push step as the only blocked piece; ship still completes).
- **docker.io pulls on the release lane** (R-21 note) — bookworm ships docker.io; accepted for v0.8, pre-baked release image documented as the future fix.
- **Runner 3.x config drift** (cache.host/bind_workdir/health_check keys) — mitigation: `gitea-runner generate-config` is the authority at bring-up (R-9 risk 9).
- **Image size growth over releases** (~400-500MB budget, R-18) — mitigation: budget asserted in the smoke gate; multi-stage keeps toolchains out of the runtime layer.
- **Existing v0.4 binary pipeline regression** — mitigation: x64 path byte-identical (checksums + install.sh gates unchanged, pinned by the existing fixture tests).
## User-Facing Surface
1. **Gitea Actions tab** — every push runs the lanes; every tag builds a release (visible, green, machine-made).
2. **`docker run git.coreci.dev/coreci/nextcraft`** — the whole stack, one port, /health, honest degrade notice.
3. **Release downloads** — x64 + arm64 binaries, checksums, .deb packages — every release.
4. **`nextcraft guide`** — offline man-page for admins: channel recipes, upgrade paths, troubleshooting.
5. **`nextcraft doctor`** — channel-aware (container/deb/binary) with the right next-step line.
6. **`deploy/README.md`** — the one-entry-point admin runbook.
7. **Pre-commit** (opt-in for devs) — local hygiene mirroring CI.
## Happy Path (end-to-end, written BEFORE execute)
1. **Founder/rails:** tag push → release workflow builds binary + checksum, attaches to the release, pushes the image (from P2), adds arm64 + debs (from P3) — no human touches an artifact.
2. **Admin, docker channel:** fresh Debian box → `docker run -d -p 8420:8420 -v nextcraft-state:/home/nextcraft/.nextcraft git.coreci.dev/coreci/nextcraft:v0.7.x` → /health green, site HTML at :8420, boot notice explains the sandbox degrade + the privileged variant pointer.
3. **Admin, deb channel:** `dpkg -i nextcraft_0.7.x_amd64.deb` → advisory doctor → `nextcraft deploy --service` → verified single-port stack.
4. **Admin, binary channel:** `curl … install.sh | sh` → `nextcraft doctor` → `nextcraft deploy --service` (the existing path, unchanged).
5. **Admin, any channel, stuck:** `nextcraft guide` → offline recipe + troubleshooting pointers → resolved or forge link.
6. **Upgrades:** docker = pull + recreate; deb = new deb + deploy --service; binary = install.sh re-run — all idempotent per D-071.
7. **Developer:** clone → pre-commit install → push → CI lanes green before review.
## UX Acceptance Criteria
1. No silent failures anywhere in the new surface: image can't sandbox → loud notice + 503 remediation; in-container miscommands refuse with remediation; doctor names the channel and the correct next step.
2. Every channel ends at the same place: doctor/verify green on a running stack — the admin doesn't need to know the internals.
3. Releases are uniform and machine-made: same asset matrix, same gates (checksum + version), no manual artifact steps.
4. The guide works offline and agrees with the runbook (parity-tested — divergence is a test failure, not a doc drift).
5. Docs match reality: every recipe carries an evidence pointer; stale docs are a P0 at final review, not a footnote.
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).
+59 -4
View File
@@ -16,13 +16,67 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
---
## Milestone v0.9 — Payment Engine (ACTIVE, Feature, started 2026-09-29)
## Milestone v0.11 — CIRR-Style Placement Tracking and Audit (ACTIVE, Feature, started 2026-10-01)
**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 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).
**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:** real employer auth/accounts (B2B employer network — deferred, lands post-payments per A-407), multi-currency, tax/VAT handling beyond passthrough, invoice PDF generation, Stripe-specific UI components, CIRR tracking (REQ-F-020, deferred).
**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):** 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).
**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)
@@ -305,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/* |
+90 -8
View File
@@ -1,38 +1,110 @@
# Nextcraft — REQUIREMENTS.md
## v0.9 Requirements (ACTIVE — Payment Engine, Feature)
## v0.11 Requirements (Complete — Placement Tracking and Audit, Feature; shipped as v0.10.6)
**Milestone type: Feature** — 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.
**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 | open |
| 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 | open |
| 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 | open |
| 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 | open |
| 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 | open |
| 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)
@@ -326,7 +398,7 @@
| 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.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 |
---
@@ -346,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 |
+713 -1
View File
@@ -1,4 +1,716 @@
# Nextcraft — RESEARCH.md (v0.9 Phase 0 RESEARCH — Payment Engine)
# Nextcraft — RESEARCH.md (v0.11 Phase 0 RESEARCH — Placement Tracking and Audit)
## v0.11 research conclusions (Phase 0 RESEARCH, 2026-10-01)
In-tree census of the placement-tracking surface, 2026-10-01, plus
audit-convention findings (CIRR methodology, hash-chained ledgers, ATS
stage models, evidence-bundle formats) grounded in audit practice and
marked [convention] vs [pilot adaptation] — no web access in this
environment; the methodology text is written to stand on its own
definitions with CIRR kept as naming, not citation. Binding clarify
decisions D-099..D-112 are NOT re-litigated; this research
operationalizes them. Probes: marketplace/store.py (ApplicationRow +
insert_application + transition machines), employers/store.py (the
8th store + G-316 partial index + decide_claim transaction), billing/
(store.py matrix + event rows + concurrent-test precedent), api/
employers.py + marketplace.py + employers_admin.py +
marketplace_admin.py (gates + provenance + throttles), main.py
(lifespan + routers + CORS + boot warning), engine-client.ts +
pins.test.ts + packages/types/ (web rails), tests/employers/ +
tests/marketplace/ + tests/billing/ (suite layouts + conftest
resets), employer/admin web surfaces (D-109's four surfaces).
**R-67. CIRR methodology transfers as disclosure discipline; three
pilot gaps must be stated in the export methodology (R-67/R-68).**
[convention] CIRR-style audits rest on four load-bearing parts: a
placement definition (in-field role, FTE, exclusions for
self-employment), cohort construction with stated denominators,
employer-verified evidence sampled by an independent auditor, and
disclosure discipline (methodology published beside the numbers,
self-reported vs employer-verified labeled separately). [pilot
adaptation] v0.11's employer-principal attestation (authenticated
`hire` by a claim-verified employer — employers.py:173-191/:467-484,
claim approval with provenance employers/store.py:583-655) is a
stronger evidence class than learner self-report but is NOT CIRR-grade
employer verification. The methodology JSON must state three gaps
honestly: (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-attestation + operator oversight, not
third-party employment verification. D-107's distinct-learner counting
matches CIRR's placed-graduate semantics for a cumulative north star;
CIRR reports cohort RATES over denominators, which v0.11 deliberately
does not claim at pilot. Confidence 0.75.
**R-68. The methodology section writes itself from in-tree facts.**
`placement_definition` = a PlacementRow created by the hired
transition, employer-attested role_title + start_date, active at
export time, one active placement per application (partial unique, the
G-316 shape employers/store.py:159-167); `counting_rules` = distinct
learners with ≥1 active placement, all-time cumulative, revoked
excluded, cohort month = start_date calendar month (D-107);
`verification` = each evidence bundle carries the source posting +
application records + full transition ledger whose global sha256 chain
re-walks from GENESIS_HASH to head_hash (D-101); `scope` =
pilot-bounded, every placement in the DB, evidence is
employer-principal attestation. The methodology text must name the
three R-67 gaps explicitly — an audit methodology that omits its own
verification limits is A-304-class dishonesty. Cohort divergence is
deliberate and documented: employment-start month, not graduation
cohort (no graduation events exist in a marketplace). Confidence 0.85.
**R-69. D-101's global hash chain sits in the well-trodden
blockchain-less tamper-evidence lane (CT, git, rekor).** A global
linear chain with one head is the right pilot shape — one walk
recomputes everything; Merkle trees buy proof-efficiency nobody needs
at thousands of events. The UNIQUE(prev_hash) fork backstop has direct
prior-art analog; G-316 proves SQLite unique/partial indexes emit
correctly through create_all and surface IntegrityError
(employers/store.py:398-400). Canonical-serialization rules: sort_keys
+ separators pinned; datetime formatting must be NORMALIZED (R-79);
avoid floats in payload entirely (str/int/bool/None only); sha256
hexdigest is the D-087 token pattern (employers/store.py:230) —
stdlib-only, zero new deps. No backfill: pre-v0.11 submitted rows
carry only their created_at (marketplace/store.py:873-901) — honest
absence per A-304. Confidence 0.85.
**R-70. The hash covers the row's own autoincrement id → the append
must be flush-then-hash-then-commit inside one transaction, and ALL
appends funnel through the one transition_application method (D-100).**
Within one Session: read head (ORDER BY id DESC LIMIT 1), construct
row with prev_hash=head, session.flush() to obtain the id, compute
hash = sha256(canonical_json + prev_hash), set the column, commit.
Nearest in-tree template: decide_claim (employers/store.py:600-652 —
get → guard → mutate → flush → append audit → commit). Corollaries:
(1) a second append path would race on prev_hash (the UNIQUE backstop
catches it as a lost event — IntegrityError); (2) the ledger must have
NO delete path (INTEGER PRIMARY KEY reuses max+1 if the top row is
deleted — D-111's tests pin zero DELETE methods + no DELETE SQL
anywhere). Verification cost at pilot: O(n) sha256 over tiny rows —
milliseconds for thousands of events; the export embeds every event so
the auditor's walk is offline. Confidence 0.80.
**R-71. ATS prior art confirms forward-only-with-skip-forward; the
exclusions are genuinely excludable (D-099).** Real ATSs allow backward
moves because they manage scheduling and offers — which D-98 excludes.
At pilot, `submitted → hired` fast-track matches how small-scale hiring
actually proceeds. Terminals-with-no-outgoing-edges is the tree's own
discipline (billing/store.py:84-90, marketplace/store.py:609-632,
InvalidTransition → 409 at :289). No interview scheduling (no calendar
surface exists — A-408), no offers machinery (no document generation),
no email (no mail transport — D-097 precedent). The matrix constant
belongs in placements/base.py (CANONICAL_EVENT_NAMES shape,
billing/base.py:71-78); the 409-matrix test arms mirror billing's
test_legal_transitions/test_illegal_transition_raises/test_cancelled_is_terminal
trio (tests/billing/test_store.py:400-418) plus skip-forward arms.
Zero data migration: existing rows are all `submitted`
(marketplace/store.py:211) — a valid matrix start. Confidence 0.85.
**R-72. D-100's cross-store single-transaction write is mechanically
sound on the shared file.** Every D-027 store builds its own engine on
the same db_path with identical pragmas (WAL/synchronous=NORMAL/
busy_timeout=5000/foreign_keys=ON — marketplace/store.py:277-286);
multiple engines on one file is the SHIPPED topology (8 stores).
SQLModel's metadata is global — create_all on any engine creates every
registered table (employers/store.py:400), so the placements store
writes ApplicationRow through one Session on its own engine without
bringing up the marketplace store. Importing across modules is
family-sanctioned (employers/base.py:39 imports marketplace.base).
decide_claim is the exact all-or-nothing precedent
(employers/store.py:634-652). Guardrails: NO cross-family foreign keys
(G-302 ID references at read); the store must tolerate being
constructed against a fresh file (global metadata + idempotent
create_all covers it). The two-commit alternative's crash window (status
changed, no ledger row) is precisely the tamper-evidence void the
milestone sells. Confidence 0.85.
**R-73. Export determinism needs three pinned rules (D-108).**
(1) Ordering — events by id (chain order); cohort/summary lists by
pinned sorts (cohort month ascending; placements by 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. (2) generated_at exclusion must be TOTAL — compare
parsed documents field-by-field or strip the key before comparison.
(3) Float absence — every export value str/int/bool; no ratios or
percentages (counts are integers by construction, D-107). The summary
generated from the same JSON is the anti-drift rule. Zero new deps
(JSONResponse + pydantic in-tree; D-112 holds). Confidence 0.80.
**R-74. Service seams verified for D-112.** Store lifespan wiring:
the getattr-guard template at main.py:182-190 (billing)/195-200
(employers) — placements slots in beside employers; close() in the
finally block main.py:335-336; router mounting beside the employers
block main.py:400-411; learner routes mount from the existing
marketplace router (no new mount — D-104), placements_admin mounts
beside employers_admin; CORS already carries Authorization
(main.py:357 — no change); the G-11 admin boot warning main.py:251-256
covers the placements admin free. deps.py gains get_placement_store
beside get_employer_store (deps.py:98). create_all checkfirst is the
probe-verified D-095 shipped behavior (employers/store.py:398-400).
Confidence 0.90.
**R-75. API gates verified for D-104/D-105/D-106.** require_employer_session
(employers.py:173-191, 401 class, returns (session, account));
_require_claimed (employers.py:467-484, 403 + claim-CTA);
_own_posting_or_404 (employers.py:487-503). The application-scoped
cross-entity arm is the same shape through the application's posting:
fetch application → posting → employer_id != claimed → 403. G-311
gate order is test-pinned (tests/employers/test_postings_write.py:225-
234). Per-target route idiom: employers.py:635 (publish)/:687 (close).
Provenance: `"account:{account.id}"` (employers.py:792). Learner stack:
_enforce_allowlist (marketplace.py:46-52) → require_verified_age(18)
(:358) → _check_apply_rate (:359; window :66-76, module state
_APPLY_TIMES :58, cap 5/60s :63) — withdraw composes the identical
stack, actor_type=learner. Operator lane: require_admin imports from
marketplace_admin (employers_admin.py:47; guard marketplace_admin.py:
62-85, fail-closed dual-header); OPERATOR="operator"
(employers_admin.py:54); the applications-review precedent
(marketplace_admin.py:420-443) already exposes learner_id to the
operator — unchanged. Token namespaces cannot collide (D-090 —
hash-lookup vs env-compare, verified in both guards). Confidence 0.90.
**R-76. Web rails verified for D-109/D-110.** engine-client.ts
sections in order: identity :351, marketplace :545 (applyPosting :647-
656), search :659, resume :730, billing :773, entitlements :782, admin
transport :843 (adminToken :815), employer auth :918 —
SessionRequiredError :126-131, ClaimRequiredError :136, parseError
:146 (401/403 discrimination); a placements section appends after
employer billing (:1114). pins.test.ts: marketplace :48-133, billing
:203-230, employer dual-schema :267-374 (field-for-field + status-union
regex pins — the D-110 template), G-319 own-postings block :376+.
packages/types/ carries billing/employer/marketplace — placement.ts is
the fourth. Four surfaces: employer overview (app/(employer)/employer/
page.tsx:25 → employer-overview-client.tsx — real postings/counts
:162/:190-230, the mock-labeled placement metrics block :335-372
labelled "(mock — no backend computes this)" :351 — the real
placements section REPLACES that mock block); /employer/applications
page is NEW beside postings/login/register/billing/talent; job-detail-
client.tsx is the learner apply surface (apply :95-115, EngineError
409/404 discrimination :103-105, confirmation render :253 — the
status panel composes the same discipline, no new classes); moderation
page (app/(admin)/admin/moderation/page.tsx:3/:35 →
employer-network-panel.tsx:56, adminToken/locked-state :28-40) gains
the placements block. Talent-profile badge stays absent — talent pages
are v0.1 mock imports; a badge on a mock page is fabricated placement
(A-304). Confidence 0.90.
**R-77. Test suites verified for D-111.** tests/employers/ (16 files)
is the D-096 layout; tests/marketplace/conftest.py:123-131 is the
G-313-shape autouse reset (_reset_apply_rate_window clears _APPLY_TIMES
before/after every test — the withdraw route rides this limiter, so
tests/placements/conftest.py MUST replicate it or the suite
self-429s); tests/employers/conftest.py:31-33 same pattern for the two
employer throttles. Hermeticity: tmp_path DBs, state-injected stores
via create_app (tests/marketplace/conftest.py:80-120 make_client),
zero network via root conftest. Matrix arms template:
tests/billing/test_store.py:400-418. Partial-index pin template:
tests/employers/test_claims_store.py:75-88 (SELECT from sqlite_master
asserting the index SQL — applies to the placement partial unique,
D-102). Gate-order pins: tests/employers/test_postings_write.py:315
(cross-entity 403), tests/marketplace/test_marketplace_api.py:188
(invalid transitions 409). G-319 pin SPLIT needed (D-105): counts-only
stays on the own-postings list (test_postings_read.py:216); learner_id
appears on application-scoped responses for own-posting applications;
cross-entity → 403 with no application data. Confidence 0.85.
**R-78. SQLite two-store cross-write interplay safe at pilot; two WAL
facts to pin.** (1) The marketplace store's own application writes are
idempotent inserts (insert_application :873-901) that never touch
status after creation — the placements store is the ONLY status writer
post-creation; state the COLUMN-level ownership in the plan (no second
status writer outside the one-transaction discipline). (2) The tamper
test's second engine on the same file sees WAL snapshot isolation —
the mutation must commit before the verification walk reads it
(Session context-managers guarantee). Multi-session contention on one
file is already exercised in-tree (test_concurrent_consumes_one_post_left,
tests/billing/test_store.py:335). busy_timeout=5000 is generous for
hiring-transition volumes (rare events, not hot-path). Confidence 0.80.
**R-79. Three clocks, three serialization rules.** (1) start_date is
a calendar date — timezone-free, ISO YYYY-MM-DD in SQLite, cohort
month = strftime("%Y-%m"); no datetime coercion in its path. (2)
created_at/decided_at are UTC datetimes stored NAIVE in SQLite,
re-labeled tz-aware on read (_as_utc, marketplace/store.py:263-267 —
the family pattern). The hash-serialization pitfall: datetime.isoformat()
emits +00:00 for aware, nothing for naive — the chain's canonical JSON
must pin ONE normalization (always _as_utc(dt).isoformat(), or a fixed
YYYY-MM-DDTHH:MM:SSZ format) and the tamper tests catch drift between
the hashing and reading paths. (3) generated_at is export-only
metadata, excluded from determinism (D-108 — a function of the request,
not the rows). Privacy (G-319 supersession): learner_id is an opaque
allowlisted handle (the G-5 roster), not name/email/DOB — identity PII
never stored (identity/base.py:25-33), the 422 scrubber's pii_fields
(main.py:439) never sees learner_id; exposure bounded to opaque handles
on the employer's own applicants, cross-entity 403 preserves
anti-enumeration. start_date is not PII-class. Confidence 0.85.
**R-80. Coherence gap found: re-hire after revoke is UNREACHABLE
through the D-099 matrix — PLAN must pin the operator re-hire as a
placement-level route.** After the first hire the application is
`hired` — terminal, no outgoing edges; D-106 validates the operator's
generic route against the same matrix, so `hired → hired` is a 409 on
every surface; the revoke itself (active → revoked, D-102) leaves the
application hired — correct — but no matrix-legal path creates the
fresh placement D-102 promises. Candidate shapes: (a) an explicit
operator re-hire route (POST /v1/placements/admin/placements/
by-application/{application_id}/re-hire {role_title, start_date})
creating the fresh PlacementRow + a `placement.rehired` ledger event
(actor operator) WITHOUT touching the application's terminal status —
audit-coherent: the ledger shows hired → revoke → rehire as three
events, never fabricating a second application transition; (b) matrix
amendment hired → hired operator-only — REJECTED (breaks the terminals
rule, and "the application was hired twice" is false); (c) revoke +
full reapplication — REJECTED (a new application may be impossible on
a closed posting, and it orphans the learner from the count — exactly
what D-102 exists to prevent). Shape (a) is coherent: placements, not
applications, are the correction lane (D-102's own framing); the
partial unique index makes it safe (revoked rows don't match WHERE
status='active' — the exact G-316 property that makes
claim-rejection-free-reclaim work, employers/store.py:159-167). The
ledger event vocabulary (D-101) admits this event type; D-111's revoke
+ re-hire test arm is unaffected. Confidence 0.80.
## Open risks (v0.11, tracked into PLAN)
1. **Re-hire-after-revoke coherence gap (R-80)** — pin the operator
re-hire as a placement-level route + `placement.rehired` ledger
event, never an application transition. Changes route list, event
vocabulary, and a test arm.
2. **Hash-after-flush write shape (R-70)** — flush-then-hash-then-commit
inside one transaction; ALL appends through the one
transition_application method; no-delete-path pin.
3. **created_at canonical serialization (R-79/R-69)** — pin ONE
normalization (_as_utc(dt).isoformat() or fixed Z-format) or the
chain breaks on its first re-read.
4. **_APPLY_TIMES limiter reset (R-77)** — tests/placements/conftest.py
must replicate the autouse reset or the suite self-429s (G-313).
5. **Methodology honesty block (R-67/R-68)** — the three pilot gaps
stated explicitly in the export methodology.
6. **Export determinism rules (R-73)** — pinned list orderings, total
generated_at exclusion, float absence.
7. **Status-column ownership (R-78)** — placements store is the only
post-creation writer of application.status; state it column-level.
8. **G-319 supersession pin split (R-77)** — counts-only stays on the
own-postings list; learner_id on application-scoped routes only.
9. **Concurrent-transition arm (R-78)** — one-winner-one-IntegrityError
on truly concurrent transition_application calls (the billing
concurrent-consumption test shape).
10. **CIRR specifics from knowledge, not live sources (R-67)** — the
methodology text stands on its own definitions; CIRR is naming,
not citation.
---
# Nextcraft — RESEARCH.md (v0.10 Phase 0 RESEARCH — B2B Employer Network)
## v0.10 research conclusions (Phase 0 RESEARCH, 2026-09-30)
In-tree census of the employer-auth/self-serve surface, 2026-09-30.
Every conclusion carries its evidence (file+line probes, live venv
probes). Binding clarify decisions D-086..D-097 are NOT re-litigated;
this research operationalizes them (the milestone is almost entirely
in-tree precedent — 10 milestones of patterns; the one external fact,
scrypt availability/cost in the shipped runtime, was probe-verified).
Probes: apps/ai-service/.venv python 3.11.2 (hashlib.scrypt + timing +
maxmem); api/marketplace_admin.py (require_admin + create_posting
gate), billing/store.py + identity/store.py + marketplace/store.py
(the D-027 family), billing_admin.py + billing.py + base.py + mock.py
+ factory.py (checkout + webhook paths), main.py (lifespan + routers +
CORS), config.py + .env.example (knob shapes), api/identity.py +
api/sandboxes.py + api/marketplace.py (throttle precedents),
tests/conftest.py + tests/billing/ + tests/marketplace/conftest.py
(suite layout), seed_corpus.json (claim-evidence reality),
apps/web/lib/engine-client.ts + employer/admin pages + pins.test.ts
(web conversion surface).
**R-55. scrypt in the shipped runtime: available, ~50 ms/hash at
D-086 params, default maxmem sufficient — pin nothing below 32 MiB.**
Probe against apps/ai-service/.venv (Python 3.11.2, requires-python
">=3.11", pyproject.toml:9): `hashlib.scrypt` is a built-in
(`<built-in function scrypt>`, OpenSSL-backed). Cost at N=2^14/r=8/p=1/
dklen=32: best-of-5 = **47.3 ms per hash** (single ~59 ms) on this box
— register and login each perform exactly ONE derivation, inside
D-096's "tens of ms, honest" budget; a full register→login→claim test
flow stays far under the 60s pytest timeout with the REAL KDF (tests
assert accept/reject behavior only, never hash bytes). Memory:
128·N·r = **16 MiB** working set; CPython's default maxmem (32 MiB)
admits these params with **no maxmem argument** (probe: default OK;
explicit `maxmem=32*1024*1024` OK; `maxmem=1024*1024` raises
`ValueError: ... memory limit exceeded`). Record for D-086
implementation: do NOT pass a maxmem below ~17 MiB; if pinned for
explicitness use 33554432. Storage format per D-086:
`scrypt$16384$8$1$<salt_b64>$<hash_b64>` (16-byte
`secrets.token_bytes` salt, self-describing so params evolve without
invalidating rows), verify via `hmac.compare_digest` over derived
bytes; params are module constants pinned by test (the
`PAST_DUE_GRACE_DAYS` shape, billing/store.py:58-61).
**R-56. The require_employer_session dep mirrors require_admin's
extract→verify→return shape; 401 = credential, 403 = authorization.**
`_extract_admin_token` (api/marketplace_admin.py:46-58) is a
dual-header extractor — `X-Admin-Token`
(`Annotated[str | None, Header(include_in_schema=False)]`) wins, else
`Authorization: Bearer <token>` via `partition(" ")` +
`scheme.strip().lower() == "bearer"`; None when neither carries a
candidate. `require_admin` (61-85) is a FastAPI dependency composed
`Depends(_extract_admin_token)` + `Depends(get_settings)` that RETURNS
`Settings`; 403 "admin token required" when the configured token is
unset OR no candidate (fail-closed, D-057), 403 "invalid admin token"
on `secrets.compare_digest` mismatch — compared on UTF-8 encodings
(`surrogatepass` on the candidate) so any bytes compare honestly,
constant-time, without the TypeError 500 (final-review F-1). No
HTTPBearer anywhere — plain Header params are the house pattern. The
employer dep (D-090) mirrors with: single `Authorization: Bearer`
extract (one principal class), sha256(token) hash-lookup against
`employer_sessions` → **401** on missing/expired/unknown (credential
failure; TTL evaluated lazily at read — G-306, no timer), **403
reserved for cross-entity authorization** (valid session touching
another employer's posting), returning session + account + claimed
employer_id. Token namespaces cannot collide: admin routes compare
against the env token, employer routes hash-lookup — one class fails
the other's gate. Second-router import precedent:
`from .marketplace_admin import require_admin`
(api/billing_admin.py:27). **Security finding:** the app-wide 422
scrubber (main.py:398-424) redacts `err["input"]` only for
`pii_fields = frozenset({"date_of_birth"})` (line 401) — a pydantic
422 on an employer register body would ECHO the raw password into the
response/client logs; the employers milestone must add `"password"`
(and `"email"`) to the scrub set (open risk 3, security-auditor
territory).
**R-57. The 8th-store recipe is fully specified by the 7th (D-095):
billing/store.py mechanics + the `employer_` prefix is MANDATORY.**
Replication checklist, all probe-verified: SQLModel table classes in
`store.py` with `__tablename__` (bare `employer` is TAKEN by
marketplace's EmployerRow, marketplace/store.py:144 — every v0.10
table carries the `employer_` prefix); `sa_type=String/JSON`
portable columns (bare Literal annotations crash sqlmodel inference —
identity/store.py:48-51); `_validate()`/`_normalize()` hooks +
module `_as_utc()` tz re-labeling (billing/store.py:362-366);
`_sqlite_connect` pragmas WAL / synchronous=NORMAL / busy_timeout=5000
/ foreign_keys=ON (369-376); Protocol + SQLite impl pair
(`EmployerStore` / `SQLiteEmployerStore`); `__init__` builds the
engine (`check_same_thread=False`) + calls `create_schema()`
(549-556); `create_schema` = `SQLModel.metadata.create_all`
(558-560) — idempotent checkfirst semantics add the 4 tables to
existing DBs at next boot (the proven billing-store compat, zero
migration); IntegrityError SURFACED never swallowed
(identity/store.py:152; billing/store.py:802-804); domain errors as
module Exception classes beside the Protocol (billing/store.py:
379-388). Unique/check-first patterns to copy: per-key UniqueConstraint
+ check-first insert (`insert_application` returns (row, created) —
marketplace/store.py:341-347, impl ~883); rowid tie-break ordering
(identity/store.py:174-183). 4-table schema (D-087/D-089):
`employer_accounts` (id PK `acc-`-style, email UNIQUE + indexed,
password_hash str, employer_id UNIQUE nullable-until-claim-approval,
created_at); `employer_sessions` (id, token_hash UNIQUE — stores ONLY
`sha256(token)`, never the token, account_id indexed, created_at,
expires_at = login + SESSION_TTL_DAYS module constant);
`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 — mirrors
BillingEventRow, billing/store.py:337-359). No `service.py` anywhere
in-tree — routes compose stores via api/ DI; the KDF lives in a small
`passwords.py` (the billing/webhooks.py crypto-core precedent), the
claim-evidence helper in base.py or store.py.
**R-58. The D-091 extraction source: gate AFTER auth, BEFORE insert;
the 402 dict hardcodes posts_used=0/posts_limit=0 — preserve exactly.**
`create_posting` (api/marketplace_admin.py:157-251) order: `require_admin`
dep (162) → billing-store presence guard (183-192:
`getattr(request.app.state, "billing_store", None)`; None → **503**
"billing entitlement store is unavailable…" — the fail-closed
final-review fix, never an ungated 201) → `consume_posting()`
(194) → two 402 arms (195-220) → posting insert (221-243). **Gate runs
before insert — a 402 consumes nothing, creates nothing.** Canonical
402 dict (both arms): `{reason, posts_used, posts_limit, upgrade_cta}`
with reason ∈ {`no_active_package`, `posting_limit_reached`} and
**posts_used/posts_limit hardcoded 0 in BOTH arms** (lines 200-201,
213-214) — the extraction preserves this byte-shape (the wire
contract `UpgradeRequiredError`, engine-client.ts:93-101, consumes
only reason + upgrade_cta; D-080/D-084 stability). `consume_posting`
itself (billing/store.py:968-1027 — G-304 single conditional UPDATE,
rowcount-checked; NoActivePackage on zero rows, PackageExhausted when
all full/expired) is UNTOUCHED. The D-091 helper
`consume_posting_entitlement(store, employer_id, upgrade_cta)` lives
in **api/** beside both callers (no billing/→api/ boundary violation),
raising `HTTPException(402, detail=<same dict>)`; admin route passes
the admin-checkout CTA, employer route the employer-checkout CTA.
Employer-arm composition mirrors "admin gate still first"
(tests/billing/test_entitlement_enforcement.py:161-170): session gate
(401/403) BEFORE any billing decision; same 503 store-missing guard.
**R-59. Employer checkout mirrors billing_admin.create_checkout
verbatim — only principal derivation + metadata provenance change.**
Source route (api/billing_admin.py:77-146): `require_admin` →
`store.get_catalog_item(item_id)` → **404 "unknown catalog item"** →
mode = subscription iff kind==plan → success/cancel URLs from
`settings.public_base_url` (G-310:
`{origin}/marketplace/pricing?checkout=success|cancelled`; empty under
mock — stripe factory validation rejects empty at boot) →
`insert_checkout_session` FIRST with unique placeholder
`provider_session_id=f"pending-{uuid4().hex[:16]}"` (final-review fix:
bare "pending" collided on the UNIQUE constraint when two checkouts
raced) → `CheckoutSessionSpec` (billing/base.py:137-164: mode, item_id,
price_cents + item_name D-078 snapshot, interval, success_url,
cancel_url, `client_reference_id` = LOCAL row id ≤200 chars,
`metadata={"employer_id": …, "operator_ref": "operator"}`) →
`provider.create_checkout_session(spec)` via
`request.app.state.payment_provider` → `bind_provider_session_id` →
`CheckoutResponse{session_id, provider_session_id, url, mode}`.
Employer arm (D-091): `POST /v1/employers/billing/checkout {item_id}`
— session-gated; unclaimed account → **403 + claim-CTA**; buyer = the
session's CLAIMED entity; metadata employer_id = claimed entity,
operator_ref → the account's provenance (D-076's "operator" becomes
account provenance); everything else verbatim. Mock-complete transpose
(D-082/G-301): `POST /v1/billing/admin/mock/complete`
(billing_admin.py:152-227) — admin-gated + `isinstance
MockPaymentProvider` defense-in-depth 404; session resolved from
LOCAL rows by provider_session_id (G-302 — payloads are untrusted ID
references); `confirm_completion` ValueError → recover via
`retrieve_session` (404 unknown-after-restart / 409 not completable /
already-complete fall-through); `provider.mint_event(
"checkout.completed", {session_id, payment_status})` →
`process_webhook_event(store=…, provider=…, raw_body=…,
signature_header=…)` (api/billing.py:58-120) — the REAL webhook path,
direct handler call. The employer arm mounts ONLY under provider=mock
(factory-conditional, the billing precedent), is
employer-SESSION-gated, and completes one's OWN session (cross-session
completion = the G-301 hole class; open risk 5). Webhook + grant
reuse unchanged: `/v1/billing/webhook` (billing.py:329-344) +
`_grant_entitlement` (billing.py:249-326; G-303 per-session
exactly-once via `purchases.checkout_session_id` UNIQUE) serve
employer checkouts verbatim — only session-row provenance differs.
Provider factory (billing/factory.py:43-71) untouched.
**R-60. main.py wiring sites (all four) + the knob shapes.**
(a) **Lifespan store block**: the billing template at main.py:182-190
(`if getattr(app.state, "billing_store", None) is None: from
.billing.store import SQLiteBillingStore; app.state.billing_store =
SQLiteBillingStore(db_path=settings.db_path)`) — the `employer_store`
wiring lands directly after (~line 191) with the same guard; its
`close()` lands in the finally block beside
`app.state.billing_store.close()` (line 325). (b) **Router mounting**:
feature routers mount with inline imports + comments (identity/
marketplace at 366-372, billing at 378-382, sitemap 387-389) —
api/__init__.py exports only the v0.2-era routers, so `api/employers.py`
+ `api/employers_admin.py` follow the inline pattern beside the
billing block. (c) **CORS**: `allow_headers=["Content-Type"]`
(main.py:341) gains `"Authorization"` (D-090) for the two-port dev +
D-038 network-mode topologies (same-origin production fetches skip
CORS); E87-class lesson — v0.3 shipped allow_methods without PUT and
every cross-origin Save died preflight — so tests/api/test_cors.py
(the policy pin, 82 lines) gains an Authorization preflight test beside
the existing `Access-Control-Request-Headers: "Content-Type"` pin
(line 79). (d) **Settings knobs**: plain ints with explanatory
comments are the shape (`identity_submits_per_min: int = 3`
config.py:157; `sandbox_creates_per_min: int = 10` config.py:66) — the
D-088 throttle knobs land as Settings ints; the session TTL is a
MODULE constant, not a setting (the PAST_DUE_GRACE_DAYS shape,
pinned by test, no env entry). `.env.example` entries mirror the
AI_ADMIN_TOKEN block (lines 78-83, commented default-off).
**R-61. Web conversion census: what converts, what stays mock, what is
greenfield.** Employer pages at apps/web/app/(employer)/employer/:
`page.tsx` (overview) imports `candidates, employers` from
'@nextcraft/mock-data' (line 10) + inline METRICS/PIPELINE constants
(18-74) — converts to a session-gated client shell with real
active-postings count + per-posting applicant counts from the
applications table; talent-match/placement metrics STAY mock-labeled
(D-092 — no backend computes them). `postings/page.tsx` imports `jobs`
(line 2) → `<PostingManager jobs={jobs}>` (19); PostingManager
(components/employer/posting-manager.tsx) derives mock
statuses/applicants (29-37) — converts to own-postings CRUD/publish/
close. `talent/page.tsx` + `talent/[candidateId]/page.tsx` import
`candidates` — **STAY MOCK** per D-092 (no candidate backend exists in
any milestone). engine-client.ts (apps/web/lib/engine-client.ts — NOT
src/lib; 787 lines): section order sandboxes(171)/files(204)/
variants(255)/grading(281)/identity(295)/defense(348)/telemetry(413)/
lab-proctor(427)/marketplace(489)/search(603)/resume(674)/billing(717)/
billing-entitlements(726) — the employer section appends beside
billing. Error classes: EngineError/EngineBusyError/VerifyRequired
Error/NotAllowlistedError/RateLimitedError/UpgradeRequiredError/
NotFoundError — **no 401 discrimination exists today** (parseError
143-151 falls through to generic EngineError): the new
`SessionRequiredError` (D-092: 401 → honest logged-out state, never a
retry loop) joins the parseError chain. Credential-storage precedent:
`adminToken()/setAdminToken()` (759-767; sessionStorage
`'nc-admin-token'`, window-guarded) is the wrapper template for the
`'nc-employer-session'` key (logout clears storage + calls revoke;
AbortController on every fetch — the A-406 discipline). **No
login/register page exists in ANY route group** (admin auth is the
G-308 per-component token input, employer-entitlement-panel.tsx:60-72)
— /employer/login + /employer/register are greenfield client pages;
the entitlement panel's load/locked/error state machine (29-46,
75-80) is the closest fetch pattern. Wire types: new
packages/types/employer.ts + `export * from './employer'` in
index.ts (the billing.ts precedent); **PostingSummary is REUSED from
marketplace.ts:99-117, never duplicated**; the entitlement view
reuses the v0.9 shapes (engine-client.ts:730-752). Pin templates: the
billing.ts pin block (pins.test.ts:205-231) + the fetch-stub test
(billing-entitlement.test.ts:34-70) are what employer pins copy
(D-093). Pricing CTAs convert from contact-path to employer-login
path in pricing-client.tsx (superseding D-079; the "Contact Sales"
enterprise pin at pins.test.ts:250 stays).
**R-62. Admin network overview lands on the moderation page below the
v0.9 entitlement panel.** app/(admin)/admin/moderation/page.tsx
renders `<ModerationTabs />` (mock queues: employerVerificationQueue/
jobReviewQueue/flaggedContent — components/admin/moderation-tabs.tsx:
13-17) + the v0.9 section `<EmployerEntitlementPanel
employerId="emp-openai">` (page lines 15-23). D-094's network sections
land as sibling `<section>` blocks below: employer accounts list
(email, claimed entity, entitlement state columns), claim queue
(evidence + domain-match signal, approve/reject actions), audit —
fed by operator routes in api/employers_admin.py (D-094): GET
/v1/employers/admin/accounts, GET /v1/employers/admin/claims, POST
/v1/employers/admin/claims/{id}/approve, POST …/claims/{id}/reject,
GET /v1/employers/admin/audit — require_admin + employer_audit_events
appends (D-089). Data-fetch pattern to copy: the entitlement panel's
`load()` (token input → setAdminToken → fetch → **403 renders an
honest locked state with the token prompt, never a retry loop** —
employer-entitlement-panel.tsx:29-46, 75-80) via the existing
`adminToken()` G-308 wrapper.
**R-63. Both D-088 throttles copy proven in-tree deque/window
mechanics.** (a) Per-key window — `_SubmitRateLimiter`
(api/identity.py:97-116): module-level `dict[str, list[float]]`,
`time.monotonic()` timestamps, `window[:] = [t for t in window if
now - t < 60.0]` slice-trim, `len(window) >= per_min` → **429
"…rate exceeded — wait a minute"**, append AFTER the check; the
module-level singleton `_rate_limiter` (116) is accepted process-local
state (D-019: one process, one box). The per-EMAIL failed-login window
copies this keyed on the SUBMITTED EMAIL (D-088: never client IP —
behind HAProxy `request.client.host` is the proxy; zero IP-trust
machinery in-tree), with two deltas: failures-only append (a
successful login CLEARS the email's window) and a 15-minute window
(900s — identity hardcodes 60.0, so window seconds parameterize; the
cap is a Settings knob, the identity_submits_per_min shape). (b)
Global cap — `_check_global_create_rate` (api/sandboxes.py:123-137):
module-level `deque[float]` (`_CREATE_TIMES`, line 50), while-popleft
older-than-60s, `len >= settings.sandbox_creates_per_min` (config.py:
66) → **429 "…retry shortly"**, append only after ALL checks pass
(a rejected create never consumes budget); the D-088 global
login+register cap copies this deque verbatim. Third precedent for
dict-of-deques: `_APPLY_TIMES` (api/marketplace.py:58) +
`_check_apply_rate` (66-76) with module constant
`_APPLY_PER_LEARNER_PER_MIN = 5` (63). Test hygiene: module state
leaks across tests — BOTH employer limiters need the reset-fixture
pattern (`_reset_apply_rate_window`, tests/marketplace/conftest.py:
123-131, `_APPLY_TIMES.clear()`).
**R-64. tests/employers/ follows the tests/billing/ layout; the
employer_store fixture is state-injection.** tests/billing/ is 9 files
(test_api/test_base/test_entitlement_enforcement/test_factory/
test_lifecycle_admin/test_mock/test_store/test_stripe_provider/
test_webhook_route/test_webhooks) with NO billing/conftest.py —
fixtures are module-local `make_client` + tmp_path stores. The pattern
to copy (tests/billing/test_entitlement_enforcement.py:28-59): store
fixture (tmp_path db, yield, close), `make_client(tmp_path, store)`
builds `Settings(provider="mock", learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path/"app.db", sandbox_dir=tmp_path/"sandboxes",
admin_token=SecretStr(ADMIN_TOKEN))` → `create_app(settings)` →
**`app.state.billing_store = store` state-injection** (the G-9
override; main.py's getattr guard adopts it — also
tests/marketplace/conftest.py:80-120) → `with TestClient(app) as c`.
Root conftest.py supplies the session-scoped
`_hermetic_default_db_path` env redirect (58-80), the `settings`
fixture (169-183), SUITE_LEARNERS + seed_verified_identity (112-166);
the cloud-free MockProvider guard (205-213) stays satisfied — auth
has no external calls, zero network by construction. tests/employers/
adds: an `employer_store` fixture injected via
`app.state.employer_store`, throttle-window reset fixtures for both
limiters (R-63), REAL-scrypt auth flows (register+login ≈ 2 hashes ≈
100 ms/test — honest per D-096, never a fake-hash path; assertions are
accept/reject behavior only), claim→admin-approve→own-posting
lifecycle + cross-entity 403s, entitlement 402 arms via direct store
grants (the `grant_posts` precedent, test_entitlement_enforcement.py:
70-85), and the employer checkout E2E through the REAL webhook path
(the D-082 proof shape: mint_event → process_webhook_event). mypy
checks tests fully (pyproject files incl. "tests", 129-133) —
stdlib-only code needs no new overrides.
**R-65. Claim evidence: every seeded employer website is an
example.com placeholder — the match signal is honestly false
corpus-wide at pilot; normalization is stdlib suffix-match.**
EmployerRow carries `website: str` (marketplace/store.py:158,
sa_type=String) + `social_links: dict` (159, JSON); the public GET
/v1/marketplace/employers/{id} (api/marketplace.py:313-339) serves raw
+ merged-provenance fields. Seed corpus (marketplace/seed_corpus.json):
all employers' websites are `https://example.com/<slug>` (lines 13,
30, 47, 64, 81, 98, 115, 132, 149, 166, …) — a real registrant's
email domain will NOT match any seeded entity's website domain, so
`match` is false corpus-wide (D-089's advisory-display premise
confirmed: the admin queue DISPLAYS the signal, the operator decides —
auto-approval would be a dishonest gate, A-304). Normalization is
stdlib-only (zero-dep discipline): `urllib.parse.urlsplit(website
).hostname` (lowercases, strips scheme/path —
"https://example.com/openai" → "example.com"), email domain after the
last "@" lowercased; registrable-domain comparison = a simple SUFFIX
MATCH on the hostname (equal, or one endswith "." + other) — no PSL
library, no tldextract; a multi-label public suffix over-matches,
acceptable for an advisory signal and recorded in the module
docstring. Evidence shape per D-089: `{account_email_domain,
entity_website_domain, match}` stored as provenance JSON on the claim
row. Store enforcement: one PENDING claim per entity + one APPROVED
owner per entity, check-first (the insert_application precedent,
marketplace/store.py:341-347) + `employer_accounts.employer_id` UNIQUE
set on approval (rejection frees the entity for a new claim). Audit:
approve/reject set decided_by/decided_at + append
employer_audit_events (the BillingEventRow shape, billing/store.py:
337-359).
**R-66. Packaging/wiring: zero new dependencies, zero workspace
changes, two .env.example knobs.** scrypt/secrets/hmac/urllib are
stdlib — pyproject.toml:10-32 unchanged (requires-python ">=3.11");
the mypy zero-override regime holds (pyproject.toml:143-151 "currently
NONE" — D-086's passlib rejection stays valid: no new dep, no first
ignore_missing_imports). packages/types/employer.ts is a new file in
the EXISTING @nextcraft/types package + one index.ts export (the
billing.ts precedent); no package.json/pnpm-workspace/turbo changes
(no new apps/packages — D-031 extend pattern). .env.example gains the
D-088 throttle knobs beside the AI_ADMIN_TOKEN block (lines 78-83
pattern: commented, default-off, AI_ prefix per config.py:24); the
session TTL is a module constant (NO env entry — pinned by test).
Backend tree additions are exactly: ai_service/employers/, the two
api/employers*.py routers, api/employer postings/billing routes, the
D-091 api/ helper + its adoption in marketplace_admin.create_posting,
config.py knobs, main.py wiring (store + routers + CORS header),
tests/employers/, .env.example. Web additions: the engine-client
employer section, packages/types/employer.ts + pins, employer page
conversions + login/register pages, admin overview sections, pricing
CTA conversion.
## Open risks (v0.10, tracked into PLAN)
1. **scrypt CPU on concurrent auth** (R-55): ~50 ms/hash
single-threaded; a login burst stacks CPU. Bounded by both
throttle windows; acceptable at pilot — record the budget in the
module docstring.
2. **CORS Authorization omission kills every cross-origin employer
fetch** (E87 class, R-60): the main.py edit + the
tests/api/test_cors.py pin MUST land in the same phase as the
client section, not after.
3. **422 password echo** (R-56): the app-wide scrubber redacts only
date_of_birth (main.py:401) — a register 422 echoes the raw
password in `err["input"]`. Add "password" (+ "email") to
pii_fields in the auth phase; pin by test (security-auditor).
4. **sessionStorage token exposure** (D-092 tradeoff): XSS during a
live tab can read 'nc-employer-session'; tab-scoped by design,
documented in surface copy; logout + revoke deletes the server row
(stored hashed — a DB leak yields nothing).
5. **Employer mock-complete must complete one's OWN session**
(R-59): a provider_session_id belonging to another buyer is the
G-301 hole class — own-session check (403 on cross-session) +
provider=mock conditional mount + session gate, pinned by test.
6. **example.com seed websites** (R-65): claim evidence match=false
corpus-wide; the queue copy must present the signal as advisory
(A-304 — never display forgeable evidence as verification).
---
## v0.9 Phase 0 RESEARCH (Payment Engine — prior milestone, retained below)
Web-verified census of the payment/billing surface, 2026-09-29. Every
conclusion carries its source (docs.stripe.com page or in-tree file probe).
+42 -10
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,23 +32,51 @@
---
## v0.9 (Active — Payment Engine)
## 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.9 (active)
### v0.11 (complete — shipped as v0.10.6, 2026-10-22)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | in progress | — | — | 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 | pending | 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 | pending | 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 | pending | 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 | pending | 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 | pending | 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 | pending | 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 |
| 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 |
---
+3 -3
View File
@@ -46,9 +46,9 @@
"projects": [],
"active_project": null,
"milestone": {
"version": "v0.9",
"name": "payment-engine",
"version": "v0.11",
"name": "placement-tracking",
"type": "feature",
"branch": "milestone/v0.9-payment-engine"
"branch": "milestone/v0.11-placement-tracking"
}
}
+97
View File
@@ -278,3 +278,100 @@ jobs:
|| { 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,6 +1,6 @@
# 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
+13 -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'.
+78 -14
View File
@@ -68,7 +68,16 @@ async def process_webhook_event(
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)."""
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)
@@ -95,14 +104,22 @@ async def process_webhook_event(
)
return {"status": "unmatched", "processed": False}
event = verified.event
# Dedupe VERBATIM on the provider event id (R-43/D-083).
created = store.insert_provider_event(
verified.provider_event_id, event.name, dict(event.payload)
)
if not created:
return {"status": "duplicate", "processed": False}
result = _process_canonical_event(store, event.name, dict(event.payload))
return {"status": "ok", "processed": result}
# 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(
@@ -121,10 +138,26 @@ def _process_canonical_event(
detail={"reason": "unknown session", "session_id": session_id},
)
return "unmatched"
# R-37 predicate: complete AND paid — resolved from the LOCAL
# row's snapshot (the route flips it on completion), never the
# payload.
store.complete_checkout_session(local.id, payment_status="paid")
# 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",
@@ -247,18 +280,49 @@ def _grant_entitlement(store: BillingStore, session_row: object) -> str:
},
)
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_prov_{session_row.provider_session_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"
@@ -13,6 +13,8 @@ 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
@@ -102,9 +104,15 @@ async def create_checkout(
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.
# 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="pending",
provider_session_id=f"pending-{uuid.uuid4().hex[:16]}",
employer_id=body.employer_id,
item_id=str(item["id"]),
item_name=str(item["name"]),
@@ -393,7 +401,11 @@ def manual_grant(
remediation for failed webhooks; D-083: manual provenance in the
audit log)."""
session_row = store.insert_checkout_session(
provider_session_id=f"cs_manual_{employer_id}",
# 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)",
@@ -433,6 +445,11 @@ def manual_revoke(
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(
@@ -440,7 +457,7 @@ def manual_revoke(
"WHERE employer_id = :employer_id"
),
{
"past": _utc_now_minus_day(),
"past": _utc_now_minus_day().replace(tzinfo=None),
"employer_id": employer_id,
},
)
@@ -461,9 +478,11 @@ def manual_revoke(
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) - timedelta(days=1)
return datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1)
class BillingEventRow_(BaseModel):
+15
View File
@@ -7,11 +7,13 @@ 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
@@ -94,6 +96,19 @@ def get_billing_store(request: Request) -> BillingStore:
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) --------------------
@@ -37,6 +37,7 @@ from .deps import (
get_provider,
get_settings,
)
from .entitlement import consume_posting_entitlement
logger = logging.getLogger(__name__)
@@ -164,44 +165,23 @@ async def create_posting(
"""Create a DRAFT posting (founder authoring path, D-050 admin_api
adapter — source=admin). Publish is a separate lifecycle call.
v0.9 P4 (REQ-9-004): the employer's billing entitlement composes
AFTER the admin gate — the package limit is enforced here (one
post per create), honest 402 + upgrade-CTA payload when the
employer has no active package or is out of posts. G-309: the
seeded-corpus ingest (and any future adapter) bypasses this check
by construction — enforcement exists ONLY in this admin route."""
from ..billing.store import NoActivePackage, PackageExhausted
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.
billing_store = getattr(request.app.state, "billing_store", None)
if billing_store is not None and hasattr(billing_store, "consume_posting"):
try:
billing_store.consume_posting(body.employer_id)
except NoActivePackage:
raise HTTPException(
status_code=402,
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."
),
},
) 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 — "
"buy more posts or upgrade via /v1/billing/admin/checkout."
),
},
) from None
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",
)
+25 -1
View File
@@ -438,6 +438,14 @@ class BillingStore(Protocol):
"""Check-first dedupe insert → created flag (at-least-once)."""
...
def has_provider_event(self, event_id: str) -> bool:
"""Fast dedupe probe (final-review fix): True when the event id
is already recorded — the webhook route checks BEFORE processing
and records AFTER, so a crash between the two leaves the id
un-recorded and the provider's retry re-runs the (idempotent)
effects instead of losing the grant."""
...
def create_purchase_from_session(
self, session: CheckoutSessionRow, *, posts_limit: int | None, duration_days: int | None
) -> PurchaseRow:
@@ -726,6 +734,15 @@ class SQLiteBillingStore:
# -- provider events ----------------------------------------------------
def has_provider_event(self, event_id: str) -> bool:
"""Fast dedupe probe for the webhook route's crash-safe ordering
(see the BillingStore protocol note)."""
with Session(self._engine) as session:
existing = session.exec(
select(ProviderEventRow).where(ProviderEventRow.event_id == event_id)
).one_or_none()
return existing is not None
def insert_provider_event(
self, event_id: str, event_name: str, payload: dict[str, Any]
) -> bool:
@@ -1030,7 +1047,14 @@ class SQLiteBillingStore:
row.updated_at = now
if new_status == "past_due":
row.past_due_at = now
elif new_status == "active":
else:
# Final-review fix: `past_due_at` is only ever valid IN
# past_due (the _validate invariant). The matrix allows
# past_due → cancelled/expired, and leaving the timestamp
# set made those legal moves raise ValueError — a
# customer whose payment failed and then cancelled could
# never leave past_due (the webhook recorded "already
# cancelled" without cancelling — a state hole).
row.past_due_at = None
if new_status == "cancelled":
row.cancelled_at = now
+36 -8
View File
@@ -3,7 +3,7 @@ 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
Idempotencyency-Key per logical operation (R-49: a double-submit
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}`).
@@ -168,12 +168,25 @@ class StripePaymentProvider:
@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=body.get("status", "open"),
payment_status=body.get("payment_status", "unpaid"),
mode=body.get("mode", "payment"),
status=status, # type: ignore[arg-type]
payment_status=payment_status, # type: ignore[arg-type]
mode=mode, # type: ignore[arg-type]
expires_at=None,
)
@@ -259,16 +272,31 @@ class StripePaymentProvider:
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}"
)
return CanonicalBillingEvent(
name=mapped,
payload=dict(provider_event.get("data", {}).get("object", {})),
)
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]:
+10
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).
@@ -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()
+80 -12
View File
@@ -189,6 +189,28 @@ def create_app(settings: Settings | None = None) -> FastAPI:
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
@@ -323,6 +345,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
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)
@@ -334,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,
)
@@ -381,6 +410,27 @@ def create_app(settings: Settings | None = None) -> FastAPI:
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.
@@ -398,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()
+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}"
)
@@ -169,6 +169,41 @@ class TestEnforcement:
)
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:
@@ -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)
@@ -114,6 +114,33 @@ class TestLifecycleRoutes:
)
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",
@@ -182,6 +209,27 @@ class TestManualGrantRevoke:
)
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:
+25 -3
View File
@@ -379,9 +379,11 @@ class TestConsumePosting:
class TestTransitionMatrix:
def _active_subscription(self, store: SQLiteBillingStore) -> str:
def _active_subscription(
self, store: SQLiteBillingStore, ref: str = "cs_sub"
) -> str:
session_row = store.insert_checkout_session(
provider_session_id="cs_sub",
provider_session_id=ref,
employer_id="emp-sub",
item_id="plan-pro",
item_name="Pro",
@@ -390,7 +392,7 @@ class TestTransitionMatrix:
)
sub = store.create_subscription_from_session(
session_row,
provider_subscription_id="sub_prov",
provider_subscription_id=f"sub_prov_{ref}",
interval="month",
)
return sub.id
@@ -432,6 +434,26 @@ class TestTransitionMatrix:
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."""
@@ -241,6 +241,61 @@ class TestVerifyWebhook:
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)."""
@@ -137,6 +137,30 @@ class TestCheckoutRoute:
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
@@ -381,6 +405,75 @@ class TestMockCompleteE2E:
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:
@@ -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
@@ -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},
]
@@ -0,0 +1,585 @@
"""Placement store tests (D-111, REQ-11-001) — the ledger's adversarial
suite: chain verification, tamper, fork, concurrency, no-delete-path,
the D-099 matrix, the D-102 revoke/re-hire lane.
Real stores on tmp_path DBs, zero network by construction (no
MockTransport needed — no external calls exist), real scrypt-free
paths, honest assertions (behavior over bytes).
"""
from __future__ import annotations
import sqlite3
import uuid
from datetime import UTC, date, datetime, timedelta, timezone
from pathlib import Path
import pytest
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import (
ApplicationRow,
SQLiteMarketplaceStore,
)
from ai_service.placements.base import (
APPLICATION_STATUSES,
GENESIS_HASH,
TRANSITION_MATRIX,
InvalidTransition,
validate_transition,
)
from ai_service.placements.store import PlacementEventRow, SQLitePlacementStore
# -- helpers -----------------------------------------------------------------
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
"""Insert a draft posting via the store's own path → its id."""
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",
required_competencies=["orchestration"],
skills=["python"],
)
)
return posting.id
def _apply(store: SQLiteMarketplaceStore, posting_id: str, learner_id: str) -> ApplicationRow:
application, _created = store.insert_application(posting_id, learner_id)
with Session(store._engine) as session:
row = session.get(ApplicationRow, application.id)
assert row is not None
return row
def _hire(
store: SQLitePlacementStore,
application_id: str,
actor: str = "operator",
now: datetime | None = None,
) -> object:
return store.transition_application(
application_id,
"hired",
actor_type="operator",
actor_ref=actor,
role_title="AI Orchestration Engineer",
start_date=date(2026, 11, 1),
now=now,
)
# -- D-099 matrix (base contracts) ---------------------------------------------
def test_genesis_hash_pinned() -> None:
assert GENESIS_HASH == "0" * 64
def test_transition_matrix_forward_only_with_skip_forward() -> None:
# forward legal
assert "in_review" in TRANSITION_MATRIX["submitted"]
# skip-forward legal (the fast-track hire — R-71)
assert "hired" in TRANSITION_MATRIX["submitted"]
assert "interview" in TRANSITION_MATRIX["submitted"]
# terminals empty
assert TRANSITION_MATRIX["hired"] == ()
assert TRANSITION_MATRIX["rejected"] == ()
assert TRANSITION_MATRIX["withdrawn"] == ()
# every status is a matrix key
for status in APPLICATION_STATUSES:
assert status in TRANSITION_MATRIX
def test_validate_transition_arms() -> None:
validate_transition("submitted", "hired") # skip-forward
validate_transition("in_review", "rejected")
with pytest.raises(InvalidTransition):
validate_transition("offer", "in_review") # backward — illegal
with pytest.raises(InvalidTransition):
validate_transition("hired", "rejected") # terminal-source
with pytest.raises(InvalidTransition):
validate_transition("submitted", "garbage") # unknown target
with pytest.raises(InvalidTransition):
validate_transition("garbage", "hired") # unknown source
# -- schema pins ---------------------------------------------------------------
def test_tables_exist_in_sqlite_master(placement_store: SQLitePlacementStore) -> None:
with sqlite3.connect(placement_store._db_path) as conn:
rows = {
name
for (name,) in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
}
assert "placement" in rows
assert "placement_event" in rows
def test_partial_unique_index_pinned(placement_store: SQLitePlacementStore) -> None:
"""G-316 shape: the partial UNIQUE index exists in sqlite_master
(the test_claims_store template)."""
with sqlite3.connect(placement_store._db_path) as conn:
rows = {
sql
for (sql,) in conn.execute(
"SELECT sql FROM sqlite_master WHERE type='index' AND name=?",
("uq_active_placement_per_application",),
)
}
assert rows, "partial index missing"
sql = rows.pop()
assert "CREATE UNIQUE INDEX" in sql
assert "status='active'" in sql
def test_no_delete_path_in_source() -> None:
"""R-70: the module carries NO delete method and NO DELETE SQL —
deleting the top ledger row would make INTEGER PRIMARY KEY reuse
max+1 (chain-integrity hole)."""
from ai_service.placements import store as placements_store_module
source = Path(placements_store_module.__file__).read_text()
assert "DELETE FROM" not in source
assert "def delete" not in source
assert "session.delete" not in source
# -- the transition + chain -----------------------------------------------------
def test_transition_flow_creates_chained_events(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
app1, placement_none = placement_store.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
)
assert app1.status == "in_review"
assert placement_none is None
app2, _ = placement_store.transition_application(
application.id, "interview", actor_type="employer", actor_ref="account:a1"
)
assert app2.status == "interview"
app3, placement = placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="AI Orchestration Engineer",
start_date=date(2026, 11, 1),
)
assert app3.status == "hired"
assert placement is not None
assert placement.status == "active"
assert placement.role_title == "AI Orchestration Engineer"
assert placement.start_date == "2026-11-01"
assert placement.learner_id == "pilot-learner"
assert placement.employer_id == "emp-test"
events = placement_store.get_application_events(application.id)
assert [e.event_type for e in events] == [
"application.in_review",
"application.interview",
"application.hired",
"placement.created",
]
# the chain walks green end-to-end
ok, head = placement_store.verify_chain()
assert ok is True
assert head == events[-1].hash
def test_hire_requires_role_title_and_start_date(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
with pytest.raises(ValueError, match="role_title"):
placement_store.transition_application(
application.id, "hired", actor_type="employer", actor_ref="account:a1"
)
with pytest.raises(ValueError, match="start_date"):
placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
)
def test_matrix_illegal_moves_raise(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
placement_store.transition_application(
application.id, "offer", actor_type="employer", actor_ref="account:a1"
)
# backward — 409 class
with pytest.raises(InvalidTransition):
placement_store.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
)
# terminal-source after hire
placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 12, 1),
)
with pytest.raises(InvalidTransition):
placement_store.transition_application(
application.id, "rejected", actor_type="employer", actor_ref="account:a1"
)
# unknown application
with pytest.raises(InvalidTransition):
placement_store.transition_application(
"app-nonexistent", "in_review", actor_type="operator", actor_ref="operator"
)
def test_unknown_status_rejected(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
with pytest.raises(InvalidTransition):
placement_store.transition_application(
application.id, "garbage", actor_type="operator", actor_ref="operator"
)
def test_tz_round_trip_canonical_stable(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""R-79: naive stored → _as_utc read → canonical isoformat stable —
the hashing path and the reading path share canonical_json()."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
moment = datetime(2026, 10, 1, 12, 0, 0, tzinfo=UTC)
placement_store.transition_application(
application.id,
"in_review",
actor_type="employer",
actor_ref="account:a1",
now=moment,
)
events = placement_store.list_events()
assert len(events) == 1
raw = events[0]
# the canonical form is a pure function of the row — recompute twice
assert raw.canonical_json() == raw.canonical_json()
ok, _ = placement_store.verify_chain()
assert ok is True
def test_aware_non_utc_now_converted_chain_stays_green(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""P0-1 (security review): an aware NON-UTC `now` CONVERTS to the
true instant — the append-time and read-back canonical forms are
identical, so the chain walks green (a re-labeled offset would
hash 12:00-04:00 and re-read 12:00+00:00 — permanently broken)."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
edt = timezone(timedelta(hours=-4))
placement_store.transition_application(
application.id,
"in_review",
actor_type="employer",
actor_ref="account:a1",
now=datetime(2026, 10, 1, 12, 0, 0, tzinfo=edt),
)
ok, _ = placement_store.verify_chain()
assert ok is True
def test_actor_type_vocabulary_enforced(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""P1-1 (security review): the store boundary rejects an actor_type
outside the closed vocabulary — a bad label would be frozen in
immutable history."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
with pytest.raises(ValueError, match="actor_type"):
placement_store.transition_application(
application.id,
"in_review",
actor_type="ghost-actor",
actor_ref="account:a1",
)
def test_payload_floats_rejected(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""R-69: payload values str/int/bool/None only — floats REJECTED."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
session = Session(placement_store._engine)
try:
row = PlacementEventRow(
application_id=application.id,
placement_id=None,
event_type="application.in_review",
actor_type="employer",
actor_ref="account:a1",
payload={"ratio": 0.5},
prev_hash=GENESIS_HASH,
hash="",
created_at=datetime.now(UTC),
)
with pytest.raises(ValueError, match="str/int/bool/None"):
row._validate()
finally:
session.close()
# -- tamper / fork / concurrency (the adversarial arms, D-111) ------------------
def test_tamper_detected_via_second_engine(
tmp_path: Path,
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""Direct row mutation through a SECOND engine on the same file →
the verification walk FAILS (R-78: the mutation commits before the
walk reads — WAL snapshot isolation)."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
placement_store.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
)
ok_before, _ = placement_store.verify_chain()
assert ok_before is True
with sqlite3.connect(placement_store._db_path) as conn:
conn.execute(
"UPDATE placement_event SET actor_ref = 'account:evil' WHERE id = 1"
)
conn.commit()
ok_after, _ = placement_store.verify_chain()
assert ok_after is False
def test_fork_prev_hash_surfaces_integrity_error(
placement_store: SQLitePlacementStore,
) -> None:
"""G-316 backstop class: a second row claiming the same prev_hash
→ UNIQUE refuses it, IntegrityError SURFACES."""
with pytest.raises(IntegrityError):
with Session(placement_store._engine) as session:
for _i in range(2):
row = PlacementEventRow(
application_id="app-x",
placement_id=None,
event_type="application.in_review",
actor_type="employer",
actor_ref="account:a1",
payload={},
prev_hash=GENESIS_HASH,
hash="",
created_at=datetime.now(UTC),
)
session.add(row)
session.flush()
row.hash = "computed-later"
session.flush()
session.commit()
def test_concurrent_transition_one_winner(
marketplace_store: SQLiteMarketplaceStore,
tmp_path: Path,
) -> None:
"""R-78: two stores (separate engines) race the same application —
one winner commits; the loser surfaces an honest error (never two
events on one prev_hash)."""
db_path = tmp_path / "shared.db"
store_a = SQLitePlacementStore(db_path=db_path)
store_b = SQLitePlacementStore(db_path=db_path)
marketplace = SQLiteMarketplaceStore(db_path=db_path)
try:
posting_id = _seed_posting(marketplace)
application = _apply(marketplace, posting_id, "pilot-learner")
# both read the same submitted status; A commits first
store_a.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a"
)
with pytest.raises((InvalidTransition, IntegrityError)):
store_b.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:b"
)
ok, _ = store_a.verify_chain()
assert ok is True
# exactly ONE in_review event exists
events = store_a.get_application_events(application.id)
assert [e.event_type for e in events] == ["application.in_review"]
finally:
store_a.close()
store_b.close()
marketplace.close()
# -- the D-102 correction lane ---------------------------------------------------
def test_revoke_and_rehire(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
_, placement = placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 11, 1),
)
assert placement is not None
# revoke: active → revoked + event
revoked = placement_store.revoke_placement(placement.id, "operator")
assert revoked is not None and revoked.status == "revoked"
# rehire after revoke: a FRESH active row (R-80) — decided_at =
# the re-hire event's stamp (G-326)
moment = datetime(2026, 10, 2, 9, 30, 0, tzinfo=UTC)
rehired = placement_store.rehire_placement(
application.id,
role_title="Engineer II",
start_date=date(2026, 12, 15),
actor_ref="operator",
now=moment,
)
assert rehired.status == "active"
assert rehired.id != placement.id
assert rehired.decided_at == moment
# the three-event ledger sequence: hired → revoked → rehired
events = placement_store.get_application_events(application.id)
assert [e.event_type for e in events] == [
"application.hired",
"placement.created",
"placement.revoked",
"placement.rehired",
]
# the revoked placement stays in history (D-102)
statuses = {p.status for p in placement_store.list_placements()}
assert statuses == {"active", "revoked"}
ok, _ = placement_store.verify_chain()
assert ok is True
def test_revoke_only_active(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
_, placement = placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 11, 1),
)
assert placement is not None
placement_store.revoke_placement(placement.id, "operator")
with pytest.raises(InvalidTransition):
placement_store.revoke_placement(placement.id, "operator")
assert placement_store.revoke_placement("plc-nonexistent", "operator") is None
def test_rehire_requires_hired_application(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
# not hired yet → re-hire is the correction lane for a hired app only
with pytest.raises(InvalidTransition):
placement_store.rehire_placement(
application.id,
role_title="Engineer",
start_date=date(2026, 11, 1),
actor_ref="operator",
)
def test_rehire_second_active_surfaces_integrity_error(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""The partial-unique arm: re-hire WITHOUT revoke → the partial
UNIQUE index refuses the second ACTIVE row (the caller's honest
409)."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 11, 1),
)
with pytest.raises(IntegrityError):
placement_store.rehire_placement(
application.id,
role_title="Engineer II",
start_date=date(2026, 12, 1),
actor_ref="operator",
)
def test_chain_walk_after_n_appends(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
for to_status in ("in_review", "interview", "offer", "rejected"):
placement_store.transition_application(
application.id, to_status, actor_type="employer", actor_ref="account:a1"
)
events = placement_store.list_events()
assert len(events) == 4
ok, head = placement_store.verify_chain()
assert ok is True
assert head == events[-1].hash
@@ -1,5 +1,6 @@
import { ModerationTabs } from '../../../../components/admin/moderation-tabs';
import { EmployerEntitlementPanel } from '../../../../components/admin/employer-entitlement-panel';
import { EmployerNetworkPanel } from '../../../../components/admin/employer-network-panel';
export default function MarketplaceModerationPage() {
return (
@@ -21,6 +22,18 @@ export default function MarketplaceModerationPage() {
</h2>
<EmployerEntitlementPanel employerId="emp-openai" />
</section>
{/* v0.10 P5 (REQ-10-005, D-094): the employer network overview —
* sibling sections BELOW the v0.9 entitlement panel. Employer
* accounts (email, claimed entity), the claim queue (evidence
* rendered ADVISORY, A-304; explicit approve/reject), and the
* audit trail — fed by the P2 operator routes over the admin
* transport. */}
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Employer network overview
</h2>
<EmployerNetworkPanel />
</section>
</div>
);
}
@@ -0,0 +1,31 @@
import { Badge } from '@nextcraft/ui';
import { EmployerApplicationsClient } from '../../../../components/employer/employer-applications-client';
/**
* The hiring pipeline (v0.11, REQ-11-002/003, D-109 surface 1): own
* applications with the seven D-099 status badges, per-target
* transition CTAs, and the hire form {role_title, start_date}
* (employer-attested, D-103). Session-gated shell with honest
* loading/error/empty/claim states (the A-406 board-client
* discipline). learner_id IS visible here — the G-319 supersession
* scopes it to the session's own claimed-entity postings (D-105).
*/
export default function ApplicationsPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Hiring Pipeline</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Hiring Pipeline
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Review applicants on your postings and drive them through the hiring
lifecycle — in review, interview, offer, reject, or hire. A hire
records the placement with the role title and start date you attest.
</p>
</header>
<EmployerApplicationsClient />
</div>
);
}
@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import { Badge } from '@nextcraft/ui';
import { EmployerBillingClient } from '../../../../components/employer/employer-billing-client';
/** Employer billing surface (P5, REQ-10-005, Task 5-2-01): the
* entitlement view (current package, posts used/limit bar, expiry —
* honest empty/expired states) + the self-serve buy flow over the
* v0.9 catalog, including the in-app mock-complete confirmation
* honestly labeled as the pilot mock path. A server shell with
* build-time metadata (the login-page precedent, A-411) rendering the
* session-gated client (the A-406 pattern). */
export const metadata: Metadata = {
title: 'Billing — Nextcraft Employer',
description: 'Your posting package, usage, and self-serve checkout.',
openGraph: {
title: 'Billing — Nextcraft Employer',
description: 'Your posting package, usage, and self-serve checkout.',
type: 'website',
},
};
export default function EmployerBillingPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Employer · Billing</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Billing
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Your posting package, usage, and checkout — real data from your
employer account. Purchases you complete yourself land here
immediately.
</p>
</header>
<EmployerBillingClient />
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from 'next';
import { Badge, Card, CardBody } from '@nextcraft/ui';
import { LoginClient } from '../../../../components/employer/login-client';
/** Employer sign-in (REQ-10-003, MH-3e): a server shell with build-time
* metadata (the pricing-page precedent, A-411) rendering the client
* (the A-406 pattern) — the login POST + honest states live there. */
export const metadata: Metadata = {
title: 'Employer Sign In — Nextcraft',
description: 'Sign in to your Nextcraft employer account.',
openGraph: {
title: 'Employer Sign In — Nextcraft',
description: 'Sign in to your Nextcraft employer account.',
type: 'website',
},
};
export default function EmployerLoginPage() {
return (
<div className="mx-auto flex max-w-md flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Employer · Sign in</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Sign in to your employer account
</h1>
<p className="text-slate-600 dark:text-slate-400">
Manage your postings, view real applicant counts, and track your entity&apos;s
presence on the marketplace.
</p>
</header>
<Card>
<CardBody>
<LoginClient />
</CardBody>
</Card>
</div>
);
}
+17 -223
View File
@@ -1,234 +1,28 @@
import Link from 'next/link';
import {
Briefcase,
Users,
TrendingUp,
Target,
ArrowRight,
} from 'lucide-react';
import { Badge, Card, CardBody, Avatar } from '@nextcraft/ui';
import { candidates, employers } from '@nextcraft/mock-data';
import { AnalyticsCharts } from '../../../components/employer/analytics-charts';
import { matchBadgeClasses } from '../../../lib/format';
/* -------------------------------------------------------------------------- */
/* Mock pipeline + metric data */
/* -------------------------------------------------------------------------- */
const METRICS = [
{ label: 'Active Postings', value: 7, trend: '+12%', icon: Briefcase, tone: 'primary' as const },
{ label: 'Total Applicants', value: 142, trend: '+24%', icon: Users, tone: 'accent' as const },
{ label: 'Talent Matches', value: 28, trend: '+8%', icon: Target, tone: 'violet' as const },
{ label: 'Placement Rate', value: '18%', trend: '+3%', icon: TrendingUp, tone: 'amber' as const },
];
const TONE_CLASSES: Record<string, string> = {
primary: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300',
accent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
interface PipelineStage {
name: string;
count: number;
cards: Array<{ name: string; position: string; score: number }>;
}
const PIPELINE: PipelineStage[] = [
{
name: 'Applied',
count: 8,
cards: [
{ name: 'Maya Okonkwo', position: 'AI Orchestration Engineer', score: 96 },
{ name: 'Devon Park', position: 'AI Safety Researcher', score: 91 },
{ name: 'Priya Iyer', position: 'Human-AI Designer', score: 89 },
],
},
{
name: 'Screening',
count: 5,
cards: [
{ name: 'Tomás Vega', position: 'LLM App Developer', score: 93 },
{ name: 'Sofia Marchetti', position: 'AI Governance Lead', score: 85 },
],
},
{
name: 'Interview',
count: 3,
cards: [
{ name: 'Liam Chen', position: 'Agent Reliability Eng', score: 90 },
{ name: 'Yuki Tanaka', position: 'Evaluation Engineer', score: 86 },
],
},
{
name: 'Offer',
count: 2,
cards: [{ name: 'Ingrid Solberg', position: 'AI Red Team Lead', score: 88 }],
},
{
name: 'Hired',
count: 1,
cards: [{ name: 'Hana Lindqvist', position: 'Comp Biologist', score: 88 }],
},
];
/* -------------------------------------------------------------------------- */
/* Page */
/* -------------------------------------------------------------------------- */
export default function EmployerDashboardPage() {
const employer = employers[0];
const topMatches = [...candidates].sort((a, b) => b.matchScore - a.matchScore).slice(0, 5);
import { Badge } from '@nextcraft/ui';
import { EmployerOverviewClient } from '../../../components/employer/employer-overview-client';
/**
* Employer dashboard overview (REQ-10-003, D-092): the mock imports
* + inline METRICS/PIPELINE constants are GONE — the page is a server
* shell rendering the session-gated client with REAL active-postings +
* applicant counts, the claimed-entity profile, and mock-labeled
* talent metrics (the A-406 pattern).
*/
export default function PostingsPage() {
return (
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Employer Dashboard</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Employer Dashboard
Overview
</h1>
<p className="text-slate-600 dark:text-slate-400">
Welcome back, <span className="font-semibold text-slate-800 dark:text-slate-200">{employer.name}</span>.
Here is your talent pipeline at a glance.
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Your postings, applicants, and claimed entity at a glance — real data
from your employer account.
</p>
</header>
{/* Metric cards */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{METRICS.map((m) => {
const Icon = m.icon;
return (
<Card key={m.label}>
<CardBody className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span
className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[m.tone]}`}
>
<Icon className="h-5 w-5" />
</span>
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
{m.trend}
</span>
</div>
<div className="flex flex-col">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{m.value}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">{m.label}</span>
</div>
</CardBody>
</Card>
);
})}
</section>
{/* Middle row — pipeline + talent matches */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Applicant pipeline — 2/3 width */}
<div className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Applicant Pipeline
</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{PIPELINE.reduce((s, c) => s + c.count, 0)} candidates in flight
</span>
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
{PIPELINE.map((stage) => (
<div
key={stage.name}
className="flex w-56 shrink-0 flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-900/60"
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{stage.name}
</h3>
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-200 px-1.5 text-xs font-medium text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{stage.count}
</span>
</div>
<div className="flex flex-col gap-2">
{stage.cards.map((c) => (
<div
key={c.name}
className="flex items-center gap-2 rounded-md border border-slate-200 bg-white p-2 dark:border-slate-800 dark:bg-slate-900"
>
<Avatar name={c.name} size="sm" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-xs font-medium text-slate-800 dark:text-slate-100">
{c.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{c.position}
</span>
</div>
<span
className={`inline-flex h-7 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
c.score,
)}`}
>
{c.score}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Talent matches — 1/3 width */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Talent Matches
</h2>
<Link
href="/employer/talent"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
See all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
<CardBody className="flex flex-col gap-2 p-3">
{topMatches.map((cand) => (
<Link
key={cand.id}
href={`/employer/talent/${cand.id}`}
className="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
>
<Avatar name={cand.name} src={cand.avatar} size="md" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{cand.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{cand.headline}
</span>
</div>
<span
className={`inline-flex h-8 w-12 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
cand.matchScore,
)}`}
>
{cand.matchScore}%
</span>
</Link>
))}
</CardBody>
</Card>
</div>
</section>
{/* Analytics charts */}
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">Analytics</h2>
<AnalyticsCharts />
</section>
<EmployerOverviewClient />
</div>
);
}
}
@@ -1,7 +1,13 @@
import { Badge } from '@nextcraft/ui';
import { jobs } from '@nextcraft/mock-data';
import { PostingManager } from '../../../../components/employer/posting-manager';
import { OwnPostingsClient } from '../../../../components/employer/own-postings-client';
/**
* Posting management (REQ-10-003/004, D-092): the mock job-list import
* is GONE — the page is a server shell rendering the own-postings
* lifecycle client (draft → published → closed, real applicant
* counts) with the P4 write actions: create draft, edit own draft,
* publish, close. The A-406 pattern.
*/
export default function PostingsPage() {
return (
<div className="flex flex-col gap-6">
@@ -11,12 +17,13 @@ export default function PostingsPage() {
Posting Management
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Create, edit, and track your job postings. View the applicant pipeline for each posting
and manage interview stages.
Track and manage your postings across their lifecycle — draft, published,
closed — with real applicant counts. Create drafts, edit your own drafts,
publish, and close, all from this page.
</p>
</header>
<PostingManager jobs={jobs} />
<OwnPostingsClient />
</div>
);
}
}
@@ -0,0 +1,42 @@
import type { Metadata } from 'next';
import { Badge, Card, CardBody } from '@nextcraft/ui';
import { RegisterClient } from '../../../../components/employer/register-client';
/** Employer registration (REQ-10-003, MH-3e): a server shell with
* build-time metadata (A-411) rendering the client (the A-406
* pattern) — the register POST + honest 409/429/422 states live
* there. The created account's email is UNVERIFIED (D-097) and the
* dashboard displays it as such; registration lands the account
* logged-in. */
export const metadata: Metadata = {
title: 'Employer Registration — Nextcraft',
description: 'Create a Nextcraft employer account.',
openGraph: {
title: 'Employer Registration — Nextcraft',
description: 'Create a Nextcraft employer account.',
type: 'website',
},
};
export default function EmployerRegisterPage() {
return (
<div className="mx-auto flex max-w-md flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Employer · Register</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Create your employer account
</h1>
<p className="text-slate-600 dark:text-slate-400">
Register with your work email, then claim your company&apos;s entity on the
marketplace. Email addresses are unverified at pilot — claims are reviewed
by an operator before they activate.
</p>
</header>
<Card>
<CardBody>
<RegisterClient />
</CardBody>
</Card>
</div>
);
}
+1
View File
@@ -9,6 +9,7 @@ const LINKS = [
{ label: 'Overview', href: '/employer' },
{ label: 'Talent search', href: '/employer/talent' },
{ label: 'Postings', href: '/employer/postings' },
{ label: 'Billing', href: '/employer/billing' },
];
export default function EmployerLayout({ children }: { children: ReactNode }) {
@@ -0,0 +1,504 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
import type {
AdminAccountsResponse,
AdminAuditResponse,
AdminClaimsResponse,
EmployerClaim,
EmployerClaimEvidence,
PlacementOverview,
} from '@nextcraft/types';
import {
listEmployerAdminAccounts,
listEmployerAdminClaims,
listEmployerAdminAudit,
approveEmployerAdminClaim,
rejectEmployerAdminClaim,
getPlacementOverview,
getPlacementExport,
getPlacementExportSummary,
setAdminToken,
EngineError,
} from '../../lib/engine-client';
/**
* Employer network overview (REQ-10-005, D-094, R-62): the operator's
* three-section view over the P2 operator routes — employer accounts
* (email, claimed entity, created_at columns), the claim queue
* (evidence + domain-match signal rendered ADVISORY — never
* verification, A-304; explicit approve/reject actions), and the
* audit trail. Fed by the engine-client admin transport over the
* existing adminToken() G-308 wrapper.
*
* Locked-state design (the entitlement-panel load() pattern, R-62):
* every route is admin-gated (fail-closed 403), so 403 renders the
* honest locked state with the token prompt — NEVER a retry loop. The
* token is persisted via setAdminToken (sessionStorage, never
* logged); an empty token never dispatches a fetch.
*/
type Decision = 'approve' | 'reject';
/** Read the advisory domain-match signal off a claim's stored evidence
* snapshot (A-304: the signal informs, the operator decides — never
* presented as verification). The evidence is claim-time provenance
* JSON; a missing/malformed snapshot renders as unavailable, never a
* fabricated match. */
function evidenceSignal(
claim: EmployerClaim,
): { evidence: Partial<EmployerClaimEvidence> | null; match: boolean | null } {
const evidence = claim.evidence as Partial<EmployerClaimEvidence> | null;
if (!evidence || typeof evidence !== 'object') return { evidence: null, match: null };
return {
evidence,
match: typeof evidence.match === 'boolean' ? evidence.match : null,
};
}
export function EmployerNetworkPanel() {
const [token, setToken] = useState('');
const [accounts, setAccounts] = useState<AdminAccountsResponse | null>(null);
const [claims, setClaims] = useState<AdminClaimsResponse | null>(null);
const [audit, setAudit] = useState<AdminAuditResponse | null>(null);
const [overview, setOverview] = useState<PlacementOverview | null>(null);
const [exportSummary, setExportSummary] = useState<string | null>(null);
const [exportBusy, setExportBusy] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [locked, setLocked] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [decisionBusy, setDecisionBusy] = useState<string | null>(null);
const [decisionError, setDecisionError] = useState<string | null>(null);
const load = useCallback(async () => {
const trimmed = token.trim();
if (!trimmed) return;
setAdminToken(trimmed);
setError(null);
setDecisionError(null);
setLoading(true);
const controller = new AbortController();
try {
// All three operator reads ride the SAME admin transport; a 403
// on any of them locks the whole overview (one token, one gate).
const [acct, queue, trail, placed] = await Promise.all([
listEmployerAdminAccounts(controller.signal),
listEmployerAdminClaims(controller.signal),
listEmployerAdminAudit(controller.signal),
// v0.11 P3/P4: the placements overview rides the same admin
// transport + gate; a fetch failure is honest (the rest loads).
getPlacementOverview(controller.signal).catch(() => null),
]);
setAccounts(acct);
setClaims(queue);
setAudit(trail);
setOverview(placed);
setLocked(false);
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
setLocked(true); // honest locked state — the token prompt stays
} else if (err instanceof EngineError && err.status === 401) {
setLocked(true); // credential failure on an operator route
} else {
setError(err instanceof Error ? err.message : 'Failed to load the network overview.');
}
} finally {
setLoading(false);
}
return () => controller.abort();
}, [token]);
/** The explicit operator decision (approve/reject). The audit event
* + provenance land server-side in the same transaction; the queue
* re-reads after every decision (no optimistic updates). */
const decide = async (claimId: string, decision: Decision) => {
const trimmed = token.trim();
if (!trimmed) return;
setAdminToken(trimmed);
setDecisionBusy(`${decision}:${claimId}`);
setDecisionError(null);
const controller = new AbortController();
try {
if (decision === 'approve') {
await approveEmployerAdminClaim(claimId, controller.signal);
} else {
await rejectEmployerAdminClaim(claimId, controller.signal);
}
// Re-read the queue + audit — the decision's effects render from
// the operator routes, never from optimistic local state.
const [queue, trail] = await Promise.all([
listEmployerAdminClaims(controller.signal),
listEmployerAdminAudit(controller.signal),
]);
setClaims(queue);
setAudit(trail);
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
setLocked(true);
} else if (err instanceof EngineError && err.status === 409) {
setDecisionError(err.message); // the honest constraint 409s
} else if (err instanceof EngineError && err.status === 404) {
setDecisionError('This claim no longer exists — the queue below is the current truth.');
} else {
setDecisionError(err instanceof Error ? err.message : 'The decision failed — try again.');
}
} finally {
setDecisionBusy(null);
}
};
useEffect(() => {
// No auto-fetch: an empty token never dispatches (the panel waits
// for the operator's explicit load — the entitlement-panel shape).
}, []);
/** The export actions (D-109 surface 4): the summary opens IN-TAB
* (the markdown renders in the panel); the JSON DOWNLOADS as a
* blob. G-328: the surrounding copy says RECORDED placements —
* never a verification claim. */
const runExport = async (kind: 'summary' | 'download') => {
setExportBusy(true);
setExportError(null);
const controller = new AbortController();
try {
if (kind === 'summary') {
const text = await getPlacementExportSummary(controller.signal);
setExportSummary(text);
} else {
const text = await getPlacementExport(controller.signal);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `placement-audit-export-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(url);
}
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
setLocked(true);
} else {
setExportError(err instanceof Error ? err.message : 'The export failed.');
}
} finally {
setExportBusy(false);
}
};
return (
<Card>
<CardBody className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Employer network overview
</h3>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
type="password"
placeholder="Operator token"
value={token}
onChange={(e) => setToken(e.target.value)}
className="sm:max-w-xs"
/>
<Button type="button" variant="outline" size="md" onClick={() => void load()}>
{loading ? 'Loading…' : 'Load overview'}
</Button>
</div>
</div>
{locked && (
<p className="text-sm text-slate-600 dark:text-slate-400">
Locked — the admin token is missing or invalid. Enter the operator token
to view employer accounts, the claim queue, and the audit trail.
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{decisionError && <p className="text-sm text-red-600 dark:text-red-400">{decisionError}</p>}
{accounts && claims && audit && (
<div className="flex flex-col gap-6">
{/* v0.11 P3/P4 (D-109 surface 4): the placements block —
RECORDED placements toward the 1,000 guiding goal +
per-employer lists + the export links. G-328: recorded
placements, never a verification claim. */}
{overview && (
<div className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Recorded placements
</h4>
<p className="text-sm text-slate-700 dark:text-slate-300">
<span className="text-2xl font-bold">
{overview.distinct_learners_placed}
</span>{' '}
distinct learners placed (employer-attested records, toward
the {overview.guiding_goal} guiding goal) ·{' '}
{overview.total_active_placements} active placements
</p>
{overview.by_employer.length > 0 && (
<ul className="flex flex-wrap gap-2 text-xs text-slate-600 dark:text-slate-400">
{overview.by_employer.map((bucket) => (
<li
key={bucket.employer_id}
className="rounded-full bg-slate-100 px-2 py-1 dark:bg-slate-800/60"
>
<span className="font-mono">{bucket.employer_id}</span>: {bucket.count}
</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={exportBusy}
onClick={() => {
void runExport('summary');
}}
>
{exportBusy ? 'Working…' : 'Open export summary (in-tab)'}
</Button>
<Button
variant="outline"
size="sm"
disabled={exportBusy}
onClick={() => {
void runExport('download');
}}
>
{exportBusy ? 'Working…' : 'Download audit export (JSON)'}
</Button>
</div>
{exportError && (
<p className="text-sm text-red-700 dark:text-red-300">{exportError}</p>
)}
{exportSummary && (
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-slate-50 p-3 text-xs text-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
{exportSummary}
</pre>
)}
</div>
)}
{/* Accounts list — email, claimed entity, created_at (the
UNVERIFIED marker renders as such, D-097). */}
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Accounts ({accounts.count})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-2 font-medium">Email</th>
<th className="p-2 font-medium">Email verified</th>
<th className="p-2 font-medium">Claimed entity</th>
<th className="p-2 font-medium">Created</th>
</tr>
</thead>
<tbody>
{accounts.accounts.map((a) => (
<tr
key={a.id}
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
>
<td className="p-2 font-medium text-slate-900 dark:text-slate-100">{a.email}</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
<Badge variant="warning">Unverified</Badge>
</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
{a.employer_id ?? '—'}
</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
{a.created_at ? new Date(a.created_at).toLocaleDateString() : '—'}
</td>
</tr>
))}
{accounts.accounts.length === 0 && (
<tr>
<td colSpan={4} className="p-3 text-center text-slate-500 dark:text-slate-400">
No employer accounts yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Claim queue — the stored evidence snapshot rendered
ADVISORY (A-304: the domain-match signal is a signal,
never a verification; match=false is the corpus norm).
Approve/reject are the EXPLICIT operator actions. */}
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Claim queue ({claims.count})
</h4>
<div className="flex flex-col gap-3">
{claims.claims.map((claim) => {
const { evidence, match } = evidenceSignal(claim);
const busyKey = `${claim.id}`;
const isBusy =
decisionBusy === `approve:${claim.id}` || decisionBusy === `reject:${claim.id}`;
return (
<div
key={claim.id}
className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 dark:border-slate-800"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{claim.id}
</span>
<ClaimStatusBadge status={claim.status} />
<span className="text-sm text-slate-700 dark:text-slate-300">
entity <span className="font-mono">{claim.employer_id}</span>
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
submitted {new Date(claim.created_at).toLocaleDateString()}
</span>
</div>
{/* A-304 binding: the evidence renders as ADVISORY
input with an explicit label — the operator
decision activates, the signal never does. */}
<div className="rounded-md bg-slate-50 p-2 text-xs dark:bg-slate-800/60">
<p className="font-medium text-slate-600 dark:text-slate-300">
Advisory evidence (not verification — the operator decision activates):
</p>
{evidence ? (
<ul className="mt-1 flex flex-col gap-0.5 text-slate-600 dark:text-slate-400">
<li>
claim email domain:{' '}
<span className="font-mono">
{evidence.account_email_domain ?? 'unavailable'}
</span>
</li>
<li>
entity website domain:{' '}
<span className="font-mono">
{evidence.entity_website_domain ?? 'unavailable'}
</span>
</li>
<li>
domain-match signal:{' '}
{match === null ? (
'unavailable'
) : match ? (
<Badge variant="info">match</Badge>
) : (
<Badge variant="default">no match</Badge>
)}
</li>
</ul>
) : (
<p className="mt-1 text-slate-600 dark:text-slate-400">
Evidence snapshot unavailable on this claim.
</p>
)}
</div>
{claim.decided_by && (
<p className="text-xs text-slate-500 dark:text-slate-400">
decided by {claim.decided_by}
{claim.decided_at
? ` at ${new Date(claim.decided_at).toLocaleDateString()}`
: ''}
</p>
)}
{claim.status === 'pending' && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void decide(claim.id, 'approve')}
loading={decisionBusy === `approve:${busyKey}`}
disabled={decisionBusy !== null && !isBusy}
>
Approve claim
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => void decide(claim.id, 'reject')}
loading={decisionBusy === `reject:${busyKey}`}
disabled={decisionBusy !== null && !isBusy}
>
Reject claim
</Button>
</div>
)}
</div>
);
})}
{claims.claims.length === 0 && (
<p className="text-sm text-slate-600 dark:text-slate-400">
No claims in the queue.
</p>
)}
</div>
</div>
{/* Audit trail — every decision with operator provenance. */}
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Audit trail ({audit.count})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-2 font-medium">Event</th>
<th className="p-2 font-medium">Account</th>
<th className="p-2 font-medium">Entity</th>
<th className="p-2 font-medium">Operator</th>
<th className="p-2 font-medium">When</th>
</tr>
</thead>
<tbody>
{audit.events.map((event) => (
<tr
key={event.id}
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
>
<td className="p-2 font-medium text-slate-900 dark:text-slate-100">
{event.name}
</td>
<td className="p-2 font-mono text-xs text-slate-600 dark:text-slate-400">
{event.account_id ?? '—'}
</td>
<td className="p-2 font-mono text-xs text-slate-600 dark:text-slate-400">
{event.employer_id ?? '—'}
</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
{event.operator ?? '—'}
</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
{event.created_at
? new Date(event.created_at).toLocaleString()
: '—'}
</td>
</tr>
))}
{audit.events.length === 0 && (
<tr>
<td colSpan={5} className="p-3 text-center text-slate-500 dark:text-slate-400">
No audit events yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
</CardBody>
</Card>
);
}
function ClaimStatusBadge({ status }: { status: EmployerClaim['status'] }) {
if (status === 'approved') return <Badge variant="success">Approved</Badge>;
if (status === 'pending') return <Badge variant="warning">Pending</Badge>;
return <Badge variant="default">Rejected</Badge>;
}
@@ -1,157 +0,0 @@
'use client';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { colors } from '@nextcraft/ui';
/* -------------------------------------------------------------------------- */
/* Mock chart data */
/* -------------------------------------------------------------------------- */
const postingsData = [
{ month: 'Apr', postings: 4, applications: 38 },
{ month: 'May', postings: 6, applications: 62 },
{ month: 'Jun', postings: 5, applications: 51 },
{ month: 'Jul', postings: 8, applications: 84 },
{ month: 'Aug', postings: 9, applications: 112 },
{ month: 'Sep', postings: 7, applications: 142 },
];
const sourceData = [
{ name: 'Direct', value: 40, color: colors.primary[500] },
{ name: 'AI Match', value: 30, color: colors.accent[500] },
{ name: 'Referral', value: 20, color: colors.violet[400] },
{ name: 'Job Board', value: 10, color: colors.amber[400] },
];
const placementData = [
{ month: 'Apr', placements: 2 },
{ month: 'May', placements: 5 },
{ month: 'Jun', placements: 9 },
{ month: 'Jul', placements: 14 },
{ month: 'Aug', placements: 21 },
{ month: 'Sep', placements: 28 },
];
/* -------------------------------------------------------------------------- */
/* Shared styling */
/* -------------------------------------------------------------------------- */
const tooltipStyle = {
borderRadius: 8,
border: `1px solid ${colors.neutral[200]}`,
fontSize: 12,
background: '#ffffff',
} as const;
const axisTick = { fontSize: 12, fill: colors.neutral[500] } as const;
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export function AnalyticsCharts() {
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Bar chart — Postings vs Applications */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
Postings vs Applications
</h3>
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400">Last 6 months</p>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={postingsData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={colors.neutral[200]} className="dark:opacity-20" />
<XAxis dataKey="month" tick={axisTick} stroke={colors.neutral[300]} />
<YAxis tick={axisTick} stroke={colors.neutral[300]} />
<Tooltip
contentStyle={tooltipStyle}
cursor={{ fill: 'rgba(99,102,241,0.06)' }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="postings" fill={colors.primary[500]} radius={[4, 4, 0, 0]} name="Postings" />
<Bar dataKey="applications" fill={colors.accent[500]} radius={[4, 4, 0, 0]} name="Applications" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Donut chart — Applicant Sources */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
Applicant Sources
</h3>
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400">Where candidates come from</p>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={sourceData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={3}
>
{sourceData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} formatter={(value) => `${value}%`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
{/* Line chart — Placement Trends */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
Placement Trends
</h3>
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400">Cumulative placements</p>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={placementData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<defs>
<linearGradient id="placementStroke" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={colors.primary[500]} />
<stop offset="100%" stopColor={colors.accent[500]} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={colors.neutral[200]} />
<XAxis dataKey="month" tick={axisTick} stroke={colors.neutral[300]} />
<YAxis tick={axisTick} stroke={colors.neutral[300]} />
<Tooltip contentStyle={tooltipStyle} />
<Line
type="monotone"
dataKey="placements"
stroke="url(#placementStroke)"
strokeWidth={3}
dot={{ r: 4, fill: colors.primary[600] }}
activeDot={{ r: 6 }}
name="Cumulative placements"
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
@@ -0,0 +1,342 @@
'use client';
import { useEffect, useState } from 'react';
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
import type {
ApplicationStatus,
EmployerApplicationSummary,
} from '@nextcraft/types';
import {
listOwnEmployerApplications,
employerMarkInReview,
employerMarkInterview,
employerMarkOffer,
employerRejectApplication,
employerHireApplication,
SessionRequiredError,
ClaimRequiredError,
EngineError,
} from '../../lib/engine-client';
/**
* The hiring-pipeline client (v0.11, REQ-11-002/003, D-109 surface
* 1): the session employer's OWN applications (every status, per-
* status facets) with per-target transition CTAs — the matrix stays
* OUT of the request bodies (D-105: each call names its target).
* The hire form carries the employer-attested {role_title,
* start_date} (D-103/G-324). Withdraw is NEVER offered here (D-099:
* employers reject; only the learner or an operator can withdraw).
*
* Honest states (A-406): loading/error/empty/claim, per-action
* pending + error; a 403 cross-entity or 409 matrix arm renders from
* EngineError.status — NO new error classes (D-110). learner_id IS
* visible (the G-319 supersession, own applicants only).
*/
const STATUS_LABELS: Record<ApplicationStatus, string> = {
submitted: 'Submitted',
in_review: 'In Review',
interview: 'Interview',
offer: 'Offer',
hired: 'Hired',
rejected: 'Rejected',
withdrawn: 'Withdrawn',
};
const STATUS_BADGE: Record<ApplicationStatus, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
submitted: 'default',
in_review: 'info',
interview: 'info',
offer: 'warning',
hired: 'success',
rejected: 'error',
withdrawn: 'default',
};
/** The next legal targets per the D-099 matrix (forward-only with
* skip-forward — the CTA set matches the store's transition set). */
const NEXT_TARGETS: Record<string, Array<{ key: string; label: string }>> = {
submitted: [
{ key: 'in-review', label: 'In Review' },
{ key: 'interview', label: 'Interview' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
in_review: [
{ key: 'interview', label: 'Interview' },
{ key: 'offer', label: 'Offer' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
interview: [
{ key: 'offer', label: 'Offer' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
offer: [
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
};
export function EmployerApplicationsClient() {
const [applications, setApplications] = useState<EmployerApplicationSummary[] | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// per-application action state: the honest pending/error pair
const [pending, setPending] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
// the hire form: which application id is being hired + its fields
const [hireFor, setHireFor] = useState<string | null>(null);
const [roleTitle, setRoleTitle] = useState('');
const [startDate, setStartDate] = useState('');
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const body = await listOwnEmployerApplications(controller.signal);
if (!controller.signal.aborted) setApplications(body.applications);
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // the honest claim-CTA state
} else {
setError(err instanceof Error ? err.message : 'Failed to load applications.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, []);
async function runTransition(
application: EmployerApplicationSummary,
target: 'in-review' | 'interview' | 'offer' | 'reject',
): Promise<void> {
setActionError(null);
setPending(application.id);
try {
const dispatch = {
'in-review': employerMarkInReview,
interview: employerMarkInterview,
offer: employerMarkOffer,
reject: employerRejectApplication,
}[target];
const body = await dispatch(application.id);
if (body.application_id === application.id) {
setApplications((prev) =>
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
);
}
} catch (err) {
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else if (err instanceof EngineError) {
// the 403 cross-entity + 409 matrix arms — the honest status
setActionError(`${application.id}: ${err.message}`);
} else {
setActionError(err instanceof Error ? err.message : 'The transition failed.');
}
} finally {
setPending(null);
}
}
async function submitHire(application: EmployerApplicationSummary): Promise<void> {
setActionError(null);
setPending(application.id);
try {
const body = await employerHireApplication(application.id, {
role_title: roleTitle,
start_date: startDate,
});
setApplications((prev) =>
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
);
setHireFor(null);
setRoleTitle('');
setStartDate('');
} catch (err) {
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else {
// 422 (missing/empty fields, G-324) + 409 (matrix) render here
setActionError(err instanceof Error ? err.message : 'The hire failed.');
}
} finally {
setPending(null);
}
}
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your hiring pipeline…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<a href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</a>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Claim your company&apos;s entity first — the hiring pipeline lists
applicants on your claimed entity&apos;s postings.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The hiring pipeline is unavailable — please try again shortly.
</p>
</CardBody>
</Card>
);
}
if (!applications || applications.length === 0) {
return (
<Card>
<CardBody className="py-10 text-center text-sm text-slate-500 dark:text-slate-400">
No applications yet — applicants on your published postings appear
here as they apply.
</CardBody>
</Card>
);
}
return (
<div className="flex flex-col gap-4">
{actionError && (
<Card>
<CardBody className="py-3 text-sm text-red-700 dark:text-red-300">{actionError}</CardBody>
</Card>
)}
{applications.map((application) => {
const targets = NEXT_TARGETS[application.status] ?? [];
return (
<Card key={application.id}>
<CardBody className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Badge variant={STATUS_BADGE[application.status]}>
{STATUS_LABELS[application.status]}
</Badge>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{application.learner_id}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
applied {application.created_at ? application.created_at.slice(0, 10) : ''}
</span>
</div>
<span className="text-xs text-slate-400 dark:text-slate-500">
{application.id}
</span>
</div>
{hireFor === application.id ? (
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
Record the placement you attest (visible to the learner and
the audit export):
</p>
<Input
label="Role title"
value={roleTitle}
onChange={(e) => setRoleTitle(e.target.value)}
placeholder="AI Orchestration Engineer"
maxLength={200}
/>
<Input
label="Employment start date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
disabled={pending === application.id || roleTitle === '' || startDate === ''}
onClick={() => {
void submitHire(application);
}}
>
{pending === application.id ? 'Recording…' : 'Confirm hire'}
</Button>
<Button variant="outline" size="sm" onClick={() => setHireFor(null)}>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
{targets.map((target) => (
<Button
key={target.key}
variant={target.key === 'reject' ? 'outline' : 'secondary'}
size="sm"
disabled={pending === application.id}
onClick={() => {
if (target.key === 'hire') {
setHireFor(application.id);
setActionError(null);
} else {
void runTransition(
application,
target.key as 'in-review' | 'interview' | 'offer' | 'reject',
);
}
}}
>
{pending === application.id ? '…' : target.label}
</Button>
))}
{/* D-099: withdraw is NEVER offered to employers */}
</div>
)}
</CardBody>
</Card>
);
})}
</div>
);
}
@@ -0,0 +1,446 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
import type {
CatalogResponse,
EmployerEntitlementResponse,
EmployerCheckoutResponse,
Package,
} from '@nextcraft/types';
import {
getOwnEmployerEntitlement,
listBillingCatalog,
startOwnEmployerCheckout,
completeOwnEmployerMockCheckout,
SessionRequiredError,
ClaimRequiredError,
EngineError,
} from '../../lib/engine-client';
/**
* Employer billing surface (REQ-10-005, Task 5-2-01 — the B2B loop's
* buy arm): the entitlement view (current package, posts used/limit
* bar, expiry) + the self-serve buy flow.
*
* Honest states throughout: 401 → the logged-out state with a login
* CTA (SessionRequiredError — never a retry loop); 403 claim-CTA →
* the claim prompt (the buyer must be a claimed entity, D-076
* superseded); no active package → the honest empty state (a null
* purchase NEVER asserts expiry provenance — the wire shape cannot
* distinguish never-purchased from expired; expired windows are
* skipped lazily server-side, G-306).
*
* The buy flow: the catalog from the PUBLIC /v1/billing/catalog (the
* v0.9 listBillingCatalog REUSED — one source of truth), the
* own-checkout trigger POSTs /v1/employers/billing/checkout, and the
* in-app mock-complete confirmation is HONESTLY LABELED as the pilot
* mock path (D-082): under provider=mock no real payment exists — the
* confirmation completes one's OWN checkout session and the grant
* lands through the REAL webhook path. The admin operator path
* remains the fallback (oversight, D-094). Real limits render
* exclusively from the entitlement view (G-320b — the 402 payload's
* zeros are never rendered here or anywhere).
*/
const ENTERPRISE_CENTS = 0;
function formatPrice(cents: number): string {
if (cents === ENTERPRISE_CENTS) return 'Custom';
return `$${(cents / 100).toFixed(0)}`;
}
export function EmployerBillingClient() {
const [entitlement, setEntitlement] = useState<EmployerEntitlementResponse | null>(null);
const [catalog, setCatalog] = useState<CatalogResponse | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// The open checkout awaiting the pilot mock-complete confirmation.
const [checkout, setCheckout] = useState<EmployerCheckoutResponse | null>(null);
const [checkoutItemName, setCheckoutItemName] = useState<string>('');
const [busyId, setBusyId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [, reload] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const refetch = useCallback((signal?: AbortSignal) => {
return getOwnEmployerEntitlement(signal).then(setEntitlement);
}, []);
useEffect(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
(async () => {
try {
const ent = await getOwnEmployerEntitlement(controller.signal);
setEntitlement(ent);
// The catalog is PUBLIC (no session needed) — fetched beside
// the entitlement so the buy flow is ready the moment the
// session is valid.
setCatalog(await listBillingCatalog(controller.signal));
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-CTA state — no buyer without a claim
} else {
setError(err instanceof Error ? err.message : 'Failed to load billing.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
// `reload` re-runs the read effect after a confirmed purchase.
}, [reload]);
/** Start the own-checkout for one catalog item. */
const buy = async (item: Package) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setBusyId(item.id);
setActionError(null);
setConfirmed(false);
try {
const created = await startOwnEmployerCheckout({ item_id: item.id }, controller.signal);
setCheckout(created);
setCheckoutItemName(item.name);
if (!created.url) {
// The mock provider's honest empty-url path — the in-app
// confirmation below is the pilot completion arm.
setActionError(null);
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else if (err instanceof EngineError && err.status === 404) {
setActionError('That package is no longer in the catalog — reload the page.');
} else {
setActionError(err instanceof Error ? err.message : 'Checkout failed — try again.');
}
} finally {
if (!controller.signal.aborted) setBusyId(null);
}
};
/** The in-app pilot confirmation: complete one's OWN checkout
* session through the honest mock path. */
const confirmMockComplete = async () => {
if (!checkout) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setConfirming(true);
setActionError(null);
try {
const result = await completeOwnEmployerMockCheckout(
{ provider_session_id: checkout.provider_session_id },
controller.signal,
);
if (result.completed) {
setConfirmed(true);
setCheckout(null);
await refetch(controller.signal);
reload((n) => n + 1); // the entitlement view reflects the grant
} else {
setActionError(
`The checkout did not complete (payment status: ${result.payment_status}) — try again.`,
);
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof EngineError && err.status === 404) {
setActionError(
'Mock completion is unavailable on this deployment (it exists only under provider=mock).',
);
} else if (err instanceof EngineError && err.status === 403) {
setActionError('This checkout session belongs to another employer — it cannot be completed here.');
} else {
setActionError(err instanceof Error ? err.message : 'The completion failed — try again.');
}
} finally {
if (!controller.signal.aborted) setConfirming(false);
}
};
const cancelCheckout = () => {
setCheckout(null);
setCheckoutItemName('');
setActionError(null);
};
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your billing…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<div className="flex gap-2">
<Link href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</Link>
<Link href="/employer/register">
<Button variant="outline" size="md">Register</Button>
</Link>
</div>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Checkout buys on behalf of your claimed employer entity — claim your
company&apos;s entity first. Claims are reviewed by an operator before
they activate.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The billing service is unavailable — please retry shortly.
</p>
</CardBody>
</Card>
);
}
const purchase = entitlement?.purchase ?? null;
return (
<div className="flex flex-col gap-6">
{/* Entitlement view — the ONLY place real limits render (G-320b). */}
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Your posting package
</h2>
<Card>
<CardBody className="flex flex-col gap-3">
{purchase ? (
<>
<div className="flex items-center justify-between">
<span className="font-medium text-slate-900 dark:text-slate-100">
{purchase.package_name}
</span>
<Badge variant="success">Active</Badge>
</div>
<div className="flex items-center justify-between text-sm text-slate-700 dark:text-slate-300">
<span>Posts used</span>
<span className="font-semibold">
{purchase.posts_limit === null
? `${purchase.posts_used} (unlimited)`
: `${purchase.posts_used} / ${purchase.posts_limit}`}
</span>
</div>
{purchase.posts_limit !== null && (
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<div
className="h-full bg-primary-500"
style={{
width: `${Math.min(
100,
(purchase.posts_used / purchase.posts_limit) * 100,
)}%`,
}}
/>
</div>
)}
{purchase.expires_at && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Expires {new Date(purchase.expires_at).toLocaleDateString()}
</p>
)}
</>
) : (
<p className="text-sm text-slate-600 dark:text-slate-400">
No active posting package — buy one below to post jobs.
</p>
)}
{entitlement?.subscription ? (
<div className="flex items-center justify-between border-t border-slate-100 pt-2 text-sm dark:border-slate-800">
<span className="font-medium">{entitlement.subscription.plan_name}</span>
<Badge variant={entitlement.subscription.status === 'active' ? 'success' : 'warning'}>
{entitlement.subscription.status}
</Badge>
</div>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400">
No talent-access subscription.
</p>
)}
</CardBody>
</Card>
</section>
{/* The pilot mock-complete confirmation — honestly labeled (D-082). */}
{checkout && (
<Card>
<CardBody className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Badge variant="info">Checkout open</Badge>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{checkoutItemName}
</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
Checkout session <code className="text-xs">{checkout.provider_session_id}</code> is
open (mode: {checkout.mode}).
</p>
<p className="text-sm text-slate-700 dark:text-slate-300">
<span className="font-semibold">Pilot mock path:</span> this
deployment uses the mock payment provider — no real charge
happens. Confirming below completes your own checkout session and
grants the package through the real webhook path immediately.
</p>
{actionError && <p className="text-sm text-red-600 dark:text-red-400">{actionError}</p>}
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="primary"
size="md"
onClick={() => void confirmMockComplete()}
loading={confirming}
disabled={confirming}
>
Confirm purchase (mock — no charge)
</Button>
<Button
type="button"
variant="outline"
size="md"
onClick={cancelCheckout}
disabled={confirming}
>
Cancel checkout
</Button>
</div>
</CardBody>
</Card>
)}
{/* Grant confirmation — the honest post-webhook state. */}
{confirmed && (
<Card>
<CardBody className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge variant="success">Purchase complete</Badge>
</div>
<p className="text-sm text-slate-700 dark:text-slate-300">
Your purchase completed through the mock payment path and the
webhook granted your package — the entitlement view above now
reflects it.
</p>
</CardBody>
</Card>
)}
{/* Buy flow — the PUBLIC v0.9 catalog (listBillingCatalog REUSED). */}
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Posting packages
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Prices from the live catalog — self-serve checkout buys on your
claimed entity&apos;s behalf.
</p>
</div>
{actionError && !checkout && (
<p className="text-sm text-red-600 dark:text-red-400">{actionError}</p>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{(catalog?.packages ?? []).map((pkg) => (
<Card key={pkg.id} className="flex h-full flex-col">
<CardBody className="flex flex-1 flex-col gap-3">
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
{pkg.name}
</h3>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{formatPrice(pkg.price_cents)}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{pkg.price_cents === ENTERPRISE_CENTS
? 'contact sales'
: `${pkg.posts_limit} posts · ${pkg.duration_days} days`}
</span>
</div>
</div>
<ul className="flex flex-col gap-1 text-sm text-slate-600 dark:text-slate-400">
{pkg.features.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
<div className="mt-auto pt-1">
<Button
type="button"
variant={pkg.price_cents === ENTERPRISE_CENTS ? 'outline' : 'primary'}
size="md"
className="w-full"
onClick={() => void buy(pkg)}
loading={busyId === pkg.id}
disabled={busyId !== null && busyId !== pkg.id}
>
{pkg.price_cents === ENTERPRISE_CENTS ? 'Contact Sales' : 'Buy package'}
</Button>
</div>
</CardBody>
</Card>
))}
</div>
{catalog && catalog.packages.length === 0 && (
<p className="text-sm text-slate-600 dark:text-slate-400">
No posting packages are configured in the catalog yet.
</p>
)}
</section>
<p className="text-xs text-slate-500 dark:text-slate-400">
Pilot payments run through the mock provider (honestly labeled above) —
the grant lands through the real webhook path. Operator-assisted
checkout remains available as a fallback: contact the Nextcraft team
and an operator can complete the purchase on your behalf.
</p>
</div>
);
}
@@ -0,0 +1,462 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Briefcase, Users, TrendingUp, Target, ArrowRight } from 'lucide-react';
import { Badge, Button, Card, CardBody, Avatar } from '@nextcraft/ui';
import type {
EmployerProfile,
EmployerMe,
OwnPostingsResponse,
EmployerPlacementsResponse,
} from '@nextcraft/types';
import {
getEmployerMe,
listOwnEmployerPostings,
listOwnEmployerPlacements,
getEmployer,
SessionRequiredError,
ClaimRequiredError,
} from '../../lib/engine-client';
/**
* Employer dashboard overview (REQ-10-003, D-092 — the v0.1 mockup
* converts to real data). Session-gated client shell: no/expired
* session → the honest logged-out state with a login CTA
* (SessionRequiredError — NEVER a retry loop); unclaimed account →
* the honest claim-CTA state; claim pending → the honest
* "claim submitted, awaiting review" state (never presented as
* verified before the operator decides, A-304/D-089).
*
* REAL data: active-postings count + per-posting applicant counts
* from GET /v1/employers/postings; the claimed entity's profile via
* the public marketplace employer fetch. Talent-match/placement
* metrics STAY mock-labeled (D-092 — no backend computes them; the
* honest label rides the card). The sessionStorage session is
* tab-scoped — the token dies with the tab (documented tradeoff).
*/
export function EmployerOverviewClient() {
const [me, setMe] = useState<EmployerMe | null>(null);
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
const [profile, setProfile] = useState<EmployerProfile | null>(null);
const [placements, setPlacements] = useState<EmployerPlacementsResponse | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const meBody = await getEmployerMe(controller.signal);
setMe(meBody);
if (meBody.claimed_employer_id) {
const [own, prof, placed] = await Promise.all([
listOwnEmployerPostings(controller.signal),
getEmployer(meBody.claimed_employer_id, controller.signal).catch(() => null),
// v0.11 P3 (D-109 surface 2): REAL placements — the mock
// metric block is REPLACED (D-092: never leave a convertible
// surface on mock). A placements fetch failure is honest —
// the rest of the dashboard still renders.
listOwnEmployerPlacements(controller.signal).catch(() => null),
]);
setPostings(own);
setProfile(prof);
setPlacements(placed);
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-CTA state
} else {
setError(err instanceof Error ? err.message : 'Failed to load the dashboard.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, []);
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your employer dashboard…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<div className="flex gap-2">
<Link href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</Link>
<Link href="/employer/register">
<Button variant="outline" size="md">Register</Button>
</Link>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Sessions are tab-scoped: signing in keeps the token in this tab&apos;s
sessionStorage only.
</p>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Claim your company&apos;s entity from the marketplace so your postings,
applicant counts, and profile appear here. Claims are reviewed by an
operator before they activate.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The dashboard service is unavailable — please retry shortly.
</p>
</CardBody>
</Card>
);
}
if (!me) return null;
// Claim-pending: a claim exists and is not approved — the honest
// "submitted, awaiting review" state (A-304: never shown as verified).
const claimPending =
me.claim !== null && me.claim.status === 'pending' && me.claimed_employer_id === null;
if (claimPending) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
Claim submitted, awaiting review.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Your claim on this employer entity is pending an operator decision.
Your postings and applicant counts appear here once the claim is
approved.
</p>
</CardBody>
</Card>
);
}
const facets = postings?.facets;
const activePostings = facets?.published ?? 0;
const totalApplicants =
postings?.postings.reduce((sum, p) => sum + p.applicant_count, 0) ?? 0;
// v0.11 P3 (D-107): the active-placement count from the rows at
// query time — the honest 0 when none exist.
const activePlacements =
placements?.placements.filter((p) => p.status === 'active').length ?? 0;
return (
<div className="flex flex-col gap-8">
{/* Header */}
<header className="flex flex-col gap-2">
<Badge variant="info">Employer Dashboard</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Employer Dashboard
</h1>
<p className="text-slate-600 dark:text-slate-400">
{profile ? (
<>
Welcome back,{' '}
<span className="font-semibold text-slate-800 dark:text-slate-200">
{profile.name}
</span>
. Here is your posting pipeline at a glance.
</>
) : (
<>Your posting pipeline at a glance.</>
)}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Signed in as {me.account.email} — email unverified.
</p>
</header>
{/* Metric cards — postings/applicants/placements REAL (GET
/v1/employers/postings + /placements, v0.11 P3);
talent-match stays mock-labeled (D-092 — no backend computes
it). */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard label="Active Postings" value={activePostings} icon={Briefcase} tone="primary" />
<MetricCard label="Total Applicants" value={totalApplicants} icon={Users} tone="accent" />
<MetricCard
label="Placements Recorded"
value={activePlacements}
icon={TrendingUp}
tone="amber"
/>
<MockMetricCard label="Talent Matches" value="—" icon={Target} tone="violet" />
</section>
{/* Middle row — per-posting applicant table + claimed-entity profile */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Postings &amp; Applicants
</h2>
<Link
href="/employer/postings"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Manage postings <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
{postings && postings.postings.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-3 font-medium">Posting</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 text-right font-medium">Applicants</th>
</tr>
</thead>
<tbody>
{postings.postings.map((p) => (
<tr key={p.id} className="border-b border-slate-100 last:border-0 dark:border-slate-800/60">
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">{p.title}</td>
<td className="p-3"><PostingStatusBadge status={p.status} /></td>
<td className="p-3 text-right font-semibold text-slate-700 dark:text-slate-200">
{p.applicant_count}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<CardBody className="py-8 text-center text-slate-600 dark:text-slate-400">
No postings yet — your created postings and their applicant
counts appear here.
</CardBody>
)}
</Card>
{facets && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{facets.draft} draft · {facets.published} published · {facets.closed} closed
</p>
)}
</div>
{/* Claimed-entity profile — the public marketplace fetch
(enrichment retained, R-61). */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Entity Profile
</h2>
</div>
<Card>
{profile ? (
<CardBody className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Avatar name={profile.name} src={profile.logo} size="md" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{profile.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{profile.industry || 'Industry unknown'}
</span>
</div>
</div>
<p className="line-clamp-3 text-xs text-slate-600 dark:text-slate-400">
{profile.description}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{profile.location || 'Location unknown'} · {profile.size || 'Size unknown'}
</p>
</CardBody>
) : (
<CardBody className="py-8 text-center text-slate-600 dark:text-slate-400">
Profile unavailable right now.
</CardBody>
)}
</Card>
</div>
</section>
{/* v0.11 P3 (D-109 surface 2, REQ-11-003): REAL placements — the
hired applications with role + start date. The mock
placement block is REPLACED (D-092); talent-match analytics
stay mock-labeled (no backend computes them). */}
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Placements
</h2>
<Link
href="/employer/applications"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Manage hiring pipeline <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
{placements && placements.placements.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Learner</th>
<th className="p-3 font-medium">Start Date</th>
<th className="p-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{placements.placements.map((p) => (
<tr
key={p.id}
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
>
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">
{p.role_title}
</td>
<td className="p-3 text-slate-700 dark:text-slate-300">{p.learner_id}</td>
<td className="p-3 text-slate-700 dark:text-slate-300">{p.start_date}</td>
<td className="p-3">
{p.status === 'active' ? (
<Badge variant="success">Active</Badge>
) : (
<Badge variant="default">Revoked</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<CardBody className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
No placements recorded yet — hires you record in the hiring
pipeline appear here.
</CardBody>
)}
</Card>
</section>
{/* Mock-labeled analytics (D-092: no backend computes these). */}
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">
Analytics <span className="text-sm font-normal text-slate-500 dark:text-slate-400">(sample data)</span>
</h2>
<MockAnalyticsNotice />
</section>
</div>
);
}
/* -- metric cards ------------------------------------------------ */
const TONE_CLASSES: Record<string, string> = {
primary: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300',
accent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
function MetricCard({
label,
value,
icon: Icon,
tone,
}: {
label: string;
value: number;
icon: typeof Briefcase;
tone: string;
}) {
return (
<Card>
<CardBody className="flex flex-col gap-3">
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[tone]}`}>
<Icon className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">{value}</span>
<span className="text-sm text-slate-500 dark:text-slate-400">{label}</span>
</div>
</CardBody>
</Card>
);
}
/** D-092: mock-labeled metric — the honest label rides the card. */
function MockMetricCard(props: {
label: string;
value: string;
icon: typeof Briefcase;
tone: string;
}) {
return (
<Card>
<CardBody className="flex flex-col gap-3">
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[props.tone]}`}>
<props.icon className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">{props.value}</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{props.label} <span className="text-xs">(mock — no backend computes this)</span>
</span>
</div>
</CardBody>
</Card>
);
}
/** The real posting lifecycle badges (draft → published → closed). */
function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'closed' }) {
if (status === 'published') return <Badge variant="success">Published</Badge>;
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
return <Badge variant="default">Closed</Badge>;
}
/** D-092 honest mock label for the analytics section — the charts ride
* nowhere until a backend computes them; the copy says so. v0.11:
* placement COUNTS are now real (the section above) — only the
* talent-match analytics remain mock. */
function MockAnalyticsNotice() {
return (
<p className="text-sm text-slate-500 dark:text-slate-400">
Talent-match analytics are sample data at pilot — no backend computes them yet.
Real posting, applicant, and placement counts appear above.
</p>
);
}
@@ -0,0 +1,111 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button, Input } from '@nextcraft/ui';
import {
loginEmployer,
setEmployerSessionToken,
SessionRequiredError,
RateLimitedError,
EngineError,
} from '../../lib/engine-client';
/**
* Employer sign-in client (REQ-10-003, R-61 — the first login page in
* any route group). Honest at every state: the route's single 401
* body (no account enumeration, D-090) renders as invalid-credentials
* copy; 429 renders the throttle wait; 422 renders static validation
* copy that NEVER echoes the submitted password (the server-side
* pii_fields scrub is pinned already — the client never echoes it
* either). Success stores the tab-scoped session token and redirects
* to the dashboard.
*/
export function LoginClient() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setError(null);
setLoading(true);
try {
const session = await loginEmployer({ email, password }, controller.signal);
setEmployerSessionToken(session.token);
router.push('/employer');
} catch (err) {
if (controller.signal.aborted) return;
setError(authErrorMessage(err));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
return (
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
<Input
label="Work email"
name="email"
type="email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
label="Password"
name="password"
type="password"
autoComplete="current-password"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<Button type="submit" variant="primary" size="md" loading={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</Button>
<p className="text-sm text-slate-600 dark:text-slate-400">
No employer account yet?{' '}
<Link
href="/employer/register"
className="font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Register
</Link>
</p>
</form>
);
}
/** The honest error copy — every branch is a static literal: the
* submitted password is never interpolated into any message. */
function authErrorMessage(err: unknown): string {
if (err instanceof SessionRequiredError) {
// The login route's single 401 (unknown email and wrong password
// share one body — no account enumeration, D-090).
return 'Invalid email or password.';
}
if (err instanceof RateLimitedError) {
return 'Too many attempts — wait a minute and try again.';
}
if (err instanceof EngineError) {
if (err.status === 422) {
return 'That email or password is not valid yet — check both and try again.';
}
return err.message; // honest server detail (never contains the password)
}
return 'Sign-in failed — please try again.';
}
@@ -0,0 +1,699 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
import type {
EmployerMe,
OwnPostingsResponse,
OwnPostingSummary,
EmployerPostingCreate,
EmployerPostingEdit,
Seniority,
} from '@nextcraft/types';
import { formatSalaryRange, relativeTime, seniorityLabel } from '../../lib/format';
import {
getEmployerMe,
listOwnEmployerPostings,
createOwnEmployerPosting,
editOwnEmployerPosting,
publishOwnEmployerPosting,
closeOwnEmployerPosting,
SessionRequiredError,
ClaimRequiredError,
UpgradeRequiredError,
EngineError,
} from '../../lib/engine-client';
/**
* Own-postings lifecycle view (REQ-10-003/004, D-092 — the jobs mock
* is GONE): the claimed employer's OWN postings from GET
* /v1/employers/postings — draft → published → closed all visible,
* real per-posting applicant counts. P4 adds the WRITE actions
* (create draft, edit own draft, publish, close) over the
* engine-client employer section.
*
* Honest write states (Task 4-2-01): loading per-action, error
* per-action; 402 → the upgrade CTA rendered from the payload's
* reason + upgrade_cta ONLY (G-320b: the payload's zeroed usage
* fields are byte-stable wire bytes and are NEVER rendered as
* limits — real limits render exclusively from the entitlement
* view, P5); 403 claim-CTA → the claim prompt; 409 → the honest
* own-drafts-only copy; 404 honest. NO optimistic updates — the
* list is REFETCHED after every successful action (the read route is
* the truth source). Session-gated: logged-out → the login CTA
* (never a retry loop); unclaimed → the claim-CTA state;
* claim-pending → the honest awaiting-review state.
*/
export function OwnPostingsClient() {
const [me, setMe] = useState<EmployerMe | null>(null);
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [, reload] = useState(0);
const [selectedId, setSelectedId] = useState<string>('');
// Per-action honest states (Task 4-2-01): one busy key + one error
// message per action — never a global spinner, never a silent
// failure. The 402 upgrade state renders reason + CTA only (G-320b).
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<{ key: string; message: string } | null>(null);
const [upgradeRequired, setUpgradeRequired] = useState<{
reason: string;
upgradeCta: string;
} | null>(null);
const [showCreate, setShowCreate] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const refetch = useCallback((signal?: AbortSignal) => {
return listOwnEmployerPostings(signal).then(setPostings);
}, []);
useEffect(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
(async () => {
try {
const meBody = await getEmployerMe(controller.signal);
setMe(meBody);
const own = await listOwnEmployerPostings(controller.signal);
setPostings(own);
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-CTA state
} else {
setError(err instanceof Error ? err.message : 'Failed to load postings.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
// `reload` is the refetch trigger: runAction bumps it AFTER a
// successful write so the read route re-runs (no optimistic
// updates — the list is the truth source).
}, [reload]);
const selected: OwnPostingSummary | null =
postings?.postings.find((p) => p.id === selectedId) ?? postings?.postings[0] ?? null;
/** The shared write-action runner: per-action busy/error states, the
* 402/403/404/409 discrimination, and a REFETCH after every success
* (no optimistic updates — the read route is the truth source). */
const runAction = useCallback(
async (key: string, action: (signal: AbortSignal) => Promise<unknown>) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setActionBusy(key);
setActionError(null);
setUpgradeRequired(null);
try {
await action(controller.signal);
await refetch(controller.signal);
reload((n) => n + 1); // re-run the read effect (truth source)
return true;
} catch (err) {
if (controller.signal.aborted) return false;
if (err instanceof UpgradeRequiredError) {
// G-320b (binding): the 402 branch renders reason + the
// upgrade CTA ONLY — the payload's zeroed usage fields are
// byte-stable wire bytes and are NEVER rendered as limits;
// real limits render exclusively from the entitlement view.
setUpgradeRequired({ reason: err.reason, upgradeCta: err.upgradeCta });
} else if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-prompt on 403
} else if (err instanceof EngineError) {
setActionError({ key, message: writeActionMessage(err) });
} else {
setActionError({ key, message: 'The action failed — please try again.' });
}
return false;
} finally {
if (!controller.signal.aborted) setActionBusy(null);
}
},
[refetch],
);
const publish = (id: string) =>
runAction(`publish:${id}`, (signal) => publishOwnEmployerPosting(id, signal));
const close = (id: string) =>
runAction(`close:${id}`, (signal) => closeOwnEmployerPosting(id, signal));
const createDraft = (body: EmployerPostingCreate) =>
runAction('create', (signal) => createOwnEmployerPosting(body, signal)).then((ok) => {
if (ok) setShowCreate(false);
return ok;
});
const editDraft = (id: string, body: EmployerPostingEdit) =>
runAction(`edit:${id}`, (signal) => editOwnEmployerPosting(id, body, signal));
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your postings…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<div className="flex gap-2">
<Link href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</Link>
<Link href="/employer/register">
<Button variant="outline" size="md">Register</Button>
</Link>
</div>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Claim your company&apos;s entity to see and manage its postings here.
Claims are reviewed by an operator before they activate.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The postings service is unavailable — please retry shortly.
</p>
</CardBody>
</Card>
);
}
const claimPending =
me?.claim !== null && me?.claim !== undefined && me.claim.status === 'pending';
return (
<div className="flex flex-col gap-6">
{claimPending && (
<Card>
<CardBody className="text-sm text-slate-600 dark:text-slate-400">
Claim submitted, awaiting review — your entity&apos;s postings appear
here once an operator approves the claim.
</CardBody>
</Card>
)}
{upgradeRequired && <UpgradeNotice reason={upgradeRequired.reason} cta={upgradeRequired.upgradeCta} />}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
{/* Left panel — the real posting list */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Postings
</h2>
{/* P4 (REQ-10-004): the write actions are live — create
draft (form below), edit own draft, publish, close. */}
<Button
size="sm"
variant="primary"
type="button"
onClick={() => { setShowCreate((v) => !v); setActionError(null); }}
disabled={actionBusy !== null}
>
New draft
</Button>
</div>
<div className="flex flex-col gap-2">
{postings && postings.postings.length > 0 ? (
postings.postings.map((p) => {
const active = selected?.id === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => setSelectedId(p.id)}
className={[
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
active
? 'border-primary-500 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/30'
: 'border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{p.title}
</span>
<PostingStatusBadge status={p.status} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
<span>{p.posted_at ? relativeTime(p.posted_at) : 'not published'}</span>
<span>·</span>
<span>{p.applicant_count} applicants</span>
</div>
</button>
);
})
) : (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
No postings yet — created postings appear here.
</div>
)}
</div>
{postings && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{postings.facets.draft} draft · {postings.facets.published} published ·{' '}
{postings.facets.closed} closed
</p>
)}
</div>
{/* Right panel — detail + the P4 write actions */}
<div>
{showCreate ? (
<CreateDraftForm
busy={actionBusy === 'create'}
error={actionError?.key === 'create' ? actionError.message : null}
onSubmit={createDraft}
onCancel={() => setShowCreate(false)}
/>
) : selected ? (
<PostingDetail
key={selected.id}
posting={selected}
busyKey={actionBusy}
error={actionError}
onPublish={publish}
onClose={close}
onEdit={editDraft}
/>
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 text-slate-500 dark:border-slate-700 dark:text-slate-400">
Select a posting to view details
</div>
)}
</div>
</div>
</div>
);
}
/** The G-320b 402 surface: reason + upgrade CTA ONLY. The payload's
* zeroed usage fields are byte-stable wire bytes (the D-091 wire
* contract) and are NEVER rendered as limits — real limits render
* exclusively from the entitlement view (P5's route). The CTA routes
* to the employer billing surface. */
function UpgradeNotice({ reason, cta }: { reason: string; cta: string }) {
return (
<Card>
<CardBody className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge variant="warning">Upgrade required</Badge>
</div>
<p className="text-slate-700 dark:text-slate-300">{cta}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
Reason: {reason === 'no_active_package'
? 'your employer account has no active posting package.'
: reason === 'posting_limit_reached'
? 'your posting package has no posts left.'
: reason}
</p>
<div>
<Link href="/employer/billing">
<Button variant="primary" size="md">View packages</Button>
</Link>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Self-serve checkout lives on the billing surface — buy a package
yourself, or contact your operator to purchase one on your behalf.
</p>
</CardBody>
</Card>
);
}
/** The honest write-action error copy — every branch a literal, never
* a fabricated detail (409 own-drafts-only, 404, 422, otherwise the
* server's own honest detail). */
function writeActionMessage(err: EngineError): string {
if (err.status === 409) {
return 'Only your own drafts can be edited or published — this posting is not an editable draft.';
}
if (err.status === 404) {
return 'This posting no longer exists — it may have been removed. The list below is the current truth.';
}
if (err.status === 403) {
return 'This posting belongs to another employer entity — you can only manage your own.';
}
if (err.status === 422) {
return 'The posting details are not valid yet — check the title and salary fields.';
}
return err.message; // the honest server detail
}
function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'closed' }) {
if (status === 'published') return <Badge variant="success">Published</Badge>;
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
return <Badge variant="default">Closed</Badge>;
}
interface ActionError {
key: string;
message: string;
}
function PostingDetail({
posting,
busyKey,
error,
onPublish,
onClose,
onEdit,
}: {
posting: OwnPostingSummary;
busyKey: string | null;
error: ActionError | null;
onPublish: (id: string) => Promise<boolean>;
onClose: (id: string) => Promise<boolean>;
onEdit: (id: string, body: EmployerPostingEdit) => Promise<boolean>;
}) {
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(posting.title);
const [description, setDescription] = useState(posting.description);
const editError = error?.key === `edit:${posting.id}` ? error.message : null;
const publishBusy = busyKey === `publish:${posting.id}`;
const closeBusy = busyKey === `close:${posting.id}`;
const submitEdit = async (event: React.FormEvent) => {
event.preventDefault();
const ok = await onEdit(posting.id, { title, description });
if (ok) setEditing(false);
};
return (
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{posting.title}
</h2>
<PostingStatusBadge status={posting.status} />
</div>
{editing ? (
<form onSubmit={submitEdit} className="flex flex-col gap-4" noValidate>
<Input
label="Title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-description" className="text-sm font-medium text-slate-700 dark:text-slate-200">
Description
</label>
<textarea
id="edit-description"
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{editError && <p className="text-sm text-red-600 dark:text-red-400">{editError}</p>}
<div className="flex flex-wrap gap-2">
<Button type="submit" variant="primary" size="md" loading={busyKey === `edit:${posting.id}`}>
Save changes
</Button>
<Button
type="button"
variant="outline"
size="md"
onClick={() => setEditing(false)}
disabled={busyKey !== null}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="flex flex-col gap-4 text-sm text-slate-700 dark:text-slate-300">
<p className="whitespace-pre-line">{posting.description}</p>
{posting.required_competencies.length > 0 && (
<div className="flex flex-wrap gap-2">
{posting.required_competencies.map((c) => (
<span
key={c}
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{c}
</span>
))}
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<DetailRow label="Seniority">
{posting.seniority ? seniorityLabel(posting.seniority) : '—'}
</DetailRow>
<DetailRow label="Location">
{posting.location}
{posting.remote ? ' · Remote' : ''}
</DetailRow>
<DetailRow label="Salary">
{posting.salary_min !== null && posting.salary_max !== null
? formatSalaryRange(posting.salary_min, posting.salary_max)
: '—'}
</DetailRow>
<DetailRow label="Published">
{posting.posted_at ? relativeTime(posting.posted_at) : 'not published yet'}
</DetailRow>
</div>
{editError && <p className="text-sm text-red-600 dark:text-red-400">{editError}</p>}
{/* P4 write actions (REQ-10-004): edit own draft, publish,
close — real dispatch, per-action loading/error states,
a refetch after every success (no optimistic updates). */}
<div className="flex flex-wrap gap-2 pt-2">
{posting.status === 'draft' && (
<>
<Button
variant="primary"
size="md"
type="button"
onClick={() => setEditing(true)}
disabled={busyKey !== null}
>
Edit draft
</Button>
<Button
variant="outline"
size="md"
type="button"
onClick={() => void onPublish(posting.id)}
loading={publishBusy}
disabled={busyKey !== null && !publishBusy}
>
Publish
</Button>
</>
)}
{posting.status === 'published' && (
<Button
variant="destructive"
size="md"
type="button"
onClick={() => void onClose(posting.id)}
loading={closeBusy}
disabled={busyKey !== null && !closeBusy}
>
Close posting
</Button>
)}
</div>
</div>
)}
</div>
{/* Applicant COUNT (G-319 PII boundary: counts only — learner
identifiers never ride any employer response). */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Applicants</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{posting.applicant_count} total
</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
{posting.applicant_count === 0
? 'No applicants yet — counts accrue as learners apply.'
: `${posting.applicant_count} ${posting.applicant_count === 1 ? 'learner has' : 'learners have'} applied to this posting.`}
</p>
</div>
</div>
);
}
const SENIORITY_OPTIONS: Seniority[] = ['entry', 'mid', 'senior', 'staff', 'principal'];
function CreateDraftForm({
busy,
error,
onSubmit,
onCancel,
}: {
busy: boolean;
error: string | null;
onSubmit: (body: EmployerPostingCreate) => Promise<boolean>;
onCancel: () => void;
}) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [location, setLocation] = useState('');
const [remote, setRemote] = useState(false);
const [seniority, setSeniority] = useState<Seniority | ''>('');
const [salaryMin, setSalaryMin] = useState('');
const [salaryMax, setSalaryMax] = useState('');
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const ok = await onSubmit({
title,
description,
location,
remote,
seniority: seniority || null,
salary_min: salaryMin ? Number(salaryMin) : null,
salary_max: salaryMax ? Number(salaryMax) : null,
});
if (ok) {
setTitle('');
setDescription('');
setLocation('');
setRemote(false);
setSeniority('');
setSalaryMin('');
setSalaryMax('');
}
};
return (
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
New draft posting
</h2>
</div>
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
<Input
label="Title"
name="new-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<div className="flex flex-col gap-1.5">
<label htmlFor="new-description" className="text-sm font-medium text-slate-700 dark:text-slate-200">
Description
</label>
<textarea
id="new-description"
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input label="Location" name="new-location" value={location}
onChange={(e) => setLocation(e.target.value)} />
<div className="flex flex-col gap-1.5">
<label htmlFor="new-seniority" className="text-sm font-medium text-slate-700 dark:text-slate-200">
Seniority
</label>
<select
id="new-seniority"
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
value={seniority}
onChange={(e) => setSeniority(e.target.value as Seniority | '')}
>
<option value="">Not set</option>
{SENIORITY_OPTIONS.map((s) => (
<option key={s} value={s}>{seniorityLabel(s)}</option>
))}
</select>
</div>
<Input label="Salary minimum" name="new-salary-min" type="number" min={0}
value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} />
<Input label="Salary maximum" name="new-salary-max" type="number" min={0}
value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} />
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
<input
type="checkbox"
checked={remote}
onChange={(e) => setRemote(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-primary-600 dark:border-slate-600"
/>
Remote eligible
</label>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex flex-wrap gap-2">
<Button type="submit" variant="primary" size="md" loading={busy}>
Create draft
</Button>
<Button type="button" variant="outline" size="md" onClick={onCancel} disabled={busy}>
Cancel
</Button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Drafts are private until you publish them — creating a draft uses one post
from your package.
</p>
</form>
</div>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
{label}
</span>
<span className="font-medium text-slate-800 dark:text-slate-200">{children}</span>
</div>
);
}
@@ -1,343 +0,0 @@
'use client';
import { useState } from 'react';
import { Plus, Trash2, Save } from 'lucide-react';
import { Badge, Button } from '@nextcraft/ui';
import type { Job, Seniority } from '@nextcraft/types';
import { formatSalaryK, relativeTime, seniorityLabel } from '../../lib/format';
/* -------------------------------------------------------------------------- */
/* Mock posting list (status + applicant count derived from the Job) */
/* -------------------------------------------------------------------------- */
type PostingStatus = 'active' | 'draft' | 'expired';
interface PostingRow {
job: Job;
status: PostingStatus;
applicants: number;
}
interface ApplicantRow {
name: string;
appliedAt: string;
stage: 'Screening' | 'Interview' | 'Offer';
matchScore: number;
}
/* Deterministically derive a posting status per job id. */
function deriveStatus(job: Job, idx: number): PostingStatus {
if (idx < 8) return 'active';
if (idx < 11) return 'draft';
return 'expired';
}
function deriveApplicants(job: Job, idx: number): number {
return 4 + ((job.matchScore + idx * 7) % 18);
}
function deriveApplicantList(job: Job): ApplicantRow[] {
const names = [
'Maya Okonkwo',
'Devon Park',
'Priya Iyer',
'Tomás Vega',
'Liam Chen',
'Yuki Tanaka',
'Sofia Marchetti',
];
const stages: ApplicantRow['stage'][] = ['Screening', 'Interview', 'Offer'];
const count = 5 + (job.matchScore % 2);
const rows: ApplicantRow[] = [];
for (let i = 0; i < count; i++) {
const stage = stages[i % stages.length];
rows.push({
name: names[(job.matchScore + i) % names.length],
appliedAt: new Date(Date.UTC(2026, 7, 28 - i * 2)).toISOString(),
stage,
matchScore: 78 + ((job.matchScore + i * 3) % 18),
});
}
return rows;
}
/* -------------------------------------------------------------------------- */
/* Status badge */
/* -------------------------------------------------------------------------- */
function StatusBadge({ status }: { status: PostingStatus }) {
if (status === 'active') return <Badge variant="success">Active</Badge>;
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
return <Badge variant="default">Expired</Badge>;
}
function StageBadge({ stage }: { stage: ApplicantRow['stage'] }) {
if (stage === 'Offer') return <Badge variant="success">Offer</Badge>;
if (stage === 'Interview') return <Badge variant="info">Interview</Badge>;
return <Badge variant="warning">Screening</Badge>;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export interface PostingManagerProps {
jobs: Job[];
}
export function PostingManager({ jobs }: PostingManagerProps) {
const postings: PostingRow[] = jobs.map((job, idx) => ({
job,
status: deriveStatus(job, idx),
applicants: deriveApplicants(job, idx),
}));
const [selectedId, setSelectedId] = useState<string>(postings[0]?.job.id ?? '');
const selected = postings.find((p) => p.job.id === selectedId) ?? null;
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
{/* Left panel — posting list */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Postings
</h2>
<Button size="sm" variant="primary" icon={<Plus className="h-3.5 w-3.5" />} type="button">
New
</Button>
</div>
<div className="flex flex-col gap-2">
{postings.map((p) => {
const active = p.job.id === selectedId;
return (
<button
key={p.job.id}
type="button"
onClick={() => setSelectedId(p.job.id)}
className={[
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
active
? 'border-primary-500 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/30'
: 'border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{p.job.title}
</span>
<StatusBadge status={p.status} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
<span>{relativeTime(p.job.postedAt)}</span>
<span>·</span>
<span>{p.applicants} applicants</span>
</div>
</button>
);
})}
</div>
</div>
{/* Right panel — detail */}
<div>
{selected ? (
<PostingDetail posting={selected} />
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 text-slate-500 dark:border-slate-700 dark:text-slate-400">
Select a posting to view details
</div>
)}
</div>
</div>
);
}
function PostingDetail({ posting }: { posting: PostingRow }) {
const { job, applicants } = posting;
const applicantList = deriveApplicantList(job);
const seniorityOptions: Seniority[] = ['entry', 'mid', 'senior', 'staff', 'principal'];
const inputClass =
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100';
return (
<div className="flex flex-col gap-6">
{/* Posting form */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Edit Posting
</h2>
<StatusBadge status={posting.status} />
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Title</label>
<input className={inputClass} defaultValue={job.title} aria-label="Posting title" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Description</label>
<textarea
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
defaultValue={job.description}
aria-label="Posting description"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Required Competencies
</label>
<div className="flex flex-wrap gap-2">
{job.requiredCompetencies.map((c) => (
<span
key={c}
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{c}
</span>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Seniority</label>
<select className={inputClass} defaultValue={job.seniority} aria-label="Seniority">
{seniorityOptions.map((s) => (
<option key={s} value={s}>
{seniorityLabel(s)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Location</label>
<input className={inputClass} defaultValue={job.location} aria-label="Location" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Salary Min
</label>
<input
className={inputClass}
defaultValue={formatSalaryK(job.salaryMin)}
aria-label="Salary minimum"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Salary Max
</label>
<input
className={inputClass}
defaultValue={formatSalaryK(job.salaryMax)}
aria-label="Salary maximum"
/>
</div>
</div>
<label className="flex cursor-pointer items-center justify-between gap-2 text-sm text-slate-700 dark:text-slate-200">
<span>Remote eligible</span>
<button
type="button"
role="switch"
aria-checked={job.remote}
className={[
'relative h-6 w-11 rounded-full transition-colors',
job.remote ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-700',
].join(' ')}
>
<span
className={[
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
job.remote ? 'translate-x-5' : 'translate-x-0.5',
].join(' ')}
/>
</button>
</label>
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="primary" size="md" icon={<Save className="h-4 w-4" />} type="button">
Save Changes
</Button>
<Button variant="destructive" size="md" icon={<Trash2 className="h-4 w-4" />} type="button">
Delete Posting
</Button>
</div>
</div>
</div>
{/* Applicant list */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Applicants
</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{applicants} total · {applicantList.length} shown
</span>
</div>
{/* Table on desktop, cards on mobile */}
<div className="overflow-x-auto">
<table className="hidden w-full md:table">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="pb-2 pr-4 font-medium">Applicant</th>
<th className="pb-2 pr-4 font-medium">Applied</th>
<th className="pb-2 pr-4 font-medium">Stage</th>
<th className="pb-2 pr-4 font-medium text-right">Match</th>
</tr>
</thead>
<tbody>
{applicantList.map((a, i) => (
<tr key={i} className="border-b border-slate-100 last:border-0 dark:border-slate-800/60">
<td className="py-2.5 pr-4 text-sm font-medium text-slate-900 dark:text-slate-100">
{a.name}
</td>
<td className="py-2.5 pr-4 text-sm text-slate-600 dark:text-slate-400">
{relativeTime(a.appliedAt)}
</td>
<td className="py-2.5 pr-4"><StageBadge stage={a.stage} /></td>
<td className="py-2.5 pr-4 text-right text-sm font-semibold text-slate-700 dark:text-slate-200">
{a.matchScore}%
</td>
</tr>
))}
</tbody>
</table>
{/* Mobile cards */}
<div className="flex flex-col gap-2 md:hidden">
{applicantList.map((a, i) => (
<div
key={i}
className="flex items-center justify-between rounded-md border border-slate-200 p-3 dark:border-slate-800"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{a.name}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{relativeTime(a.appliedAt)}
</span>
</div>
<div className="flex items-center gap-2">
<StageBadge stage={a.stage} />
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{a.matchScore}%
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button, Input } from '@nextcraft/ui';
import {
registerEmployer,
setEmployerSessionToken,
SessionRequiredError,
RateLimitedError,
EngineError,
} from '../../lib/engine-client';
/**
* Employer registration client (REQ-10-003): creates the account
* (email UNVERIFIED — the route pins email_verified=false, D-097)
* and lands the user LOGGED-IN (the 201 carries a fresh session —
* store the tab-scoped token, redirect to the dashboard). Honest
* states: 409 duplicate email, 429 throttle wait, 422 validation
* copy that NEVER echoes the submitted password.
*/
export function RegisterClient() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setError(null);
setLoading(true);
try {
const session = await registerEmployer({ email, password }, controller.signal);
setEmployerSessionToken(session.token);
router.push('/employer');
} catch (err) {
if (controller.signal.aborted) return;
setError(registerErrorMessage(err));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
return (
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
<Input
label="Work email"
name="email"
type="email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
label="Password"
name="password"
type="password"
autoComplete="new-password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<Button type="submit" variant="primary" size="md" loading={loading}>
{loading ? 'Creating account…' : 'Create account'}
</Button>
<p className="text-sm text-slate-600 dark:text-slate-400">
Already registered?{' '}
<Link
href="/employer/login"
className="font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Sign in
</Link>
</p>
</form>
);
}
/** The honest error copy — every branch a static literal: the
* submitted password is never echoed (the 422 pii scrub is the
* server's arm; this is the client's). */
function registerErrorMessage(err: unknown): string {
if (err instanceof SessionRequiredError) {
return 'Sign-in required — your session is missing or expired.';
}
if (err instanceof RateLimitedError) {
return 'Too many attempts — wait a minute and try again.';
}
if (err instanceof EngineError) {
if (err.status === 409) {
return 'An account with this email already exists — sign in instead.';
}
if (err.status === 422) {
return 'Enter a valid email and a password of at least 8 characters.';
}
return err.message; // honest server detail (never contains the password)
}
return 'Registration failed — please try again.';
}
@@ -6,6 +6,8 @@ import { notFound } from 'next/navigation';
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
import type {
ApplicationConfirmation,
ApplicationStatus,
LearnerApplicationStatus,
PostingDetail,
EmployerProfile,
SearchResult,
@@ -24,8 +26,11 @@ import {
VerifyRequiredError,
applyPosting,
getEmployer,
getLearnerApplicationStatus,
getMyApplication,
getPosting,
readSearchSelection,
withdrawApplication,
} from '../../lib/engine-client';
import { segmentFromPath } from '../../lib/route-id';
@@ -54,6 +59,12 @@ export function JobDetailClient() {
// P3 G-106: the board's search handoff, read ONCE on mount (empty
// when the visit is not from a search result — no score, no panel).
const [searchSelection, setSearchSelection] = useState<SearchResult | null>(null);
// P5 (G-322): the learner's application on THIS posting — composed
// via the mine-read + the status read on every visit; never a
// client-persisted application id.
const [appStatus, setAppStatus] = useState<LearnerApplicationStatus | null>(null);
const [withdrawing, setWithdrawing] = useState(false);
const [withdrawError, setWithdrawError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
@@ -76,6 +87,28 @@ export function JobDetailClient() {
} catch {
/* employer optional for the detail view */
}
// P5 (G-322): mine-read discovery — the panel composes from this
// read, never a client-persisted application_id; 404 = no
// application → the apply CTA renders (honest absence); gate
// arms render their existing states.
try {
const mine = await getMyApplication(p.id, undefined, controller.signal);
if (controller.signal.aborted) return;
const status = await getLearnerApplicationStatus(
mine.application_id,
undefined,
controller.signal,
);
if (!controller.signal.aborted) setAppStatus(status);
} catch (mineErr) {
if (controller.signal.aborted) return;
if (mineErr instanceof EngineError && mineErr.status === 404) return;
if (mineErr instanceof VerifyRequiredError) {
setVerifyCta(mineErr.verifyCta);
}
// any other failure leaves the apply CTA as the honest
// surface — its own flow re-surfaces the gate states.
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof EngineError && err.status === 404) {
@@ -97,6 +130,14 @@ export function JobDetailClient() {
try {
const c = await applyPosting(posting.id);
setConfirmation(c);
// P5: compose the status panel from the real read (the direct
// response's application_id — never persisted).
try {
const status = await getLearnerApplicationStatus(c.application_id);
setAppStatus(status);
} catch {
/* the confirmation box renders the honest state regardless */
}
} catch (err) {
if (err instanceof VerifyRequiredError) {
setVerifyCta(err.verifyCta);
@@ -112,6 +153,35 @@ export function JobDetailClient() {
}
}
async function onWithdraw() {
if (!appStatus) return;
setWithdrawing(true);
setWithdrawError(null);
try {
const c = await withdrawApplication(appStatus.application.id);
const status = await getLearnerApplicationStatus(c.application_id);
setAppStatus(status);
} catch (err) {
if (err instanceof EngineError && err.status === 409) {
// G-323: the matrix refused — the honest terminal copy; the
// state refreshes instead of a stale withdraw button.
try {
const status = await getLearnerApplicationStatus(appStatus.application.id);
setAppStatus(status);
} catch {
/* the current render stays — the 409 copy below is honest */
}
setWithdrawError('This application is terminal — it can no longer be withdrawn.');
} else if (err instanceof EngineError && err.status === 403) {
setWithdrawError('This application belongs to another learner.');
} else {
setWithdrawError(err instanceof Error ? err.message : 'Withdraw failed.');
}
} finally {
setWithdrawing(false);
}
}
if (missing) {
notFound();
}
@@ -242,8 +312,86 @@ export function JobDetailClient() {
</div>
</div>
<div className="flex flex-col items-start gap-3 md:items-end">
{/* Apply flow (D-058): every state renders honestly. */}
{confirmation ? (
{/* Apply flow (D-058): every state renders honestly. P5
* (G-322/D-104): the status panel renders when the
* mine-read finds the application; the apply CTA only
* when it does not. */}
{appStatus ? (
<div
role="status"
className="flex w-full flex-col gap-3 rounded-lg border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900/40"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Your application
</span>
<Badge
variant={appStatus.application.status === 'hired' ? 'success' : 'default'}
>
{applicationStatusLabel(appStatus.application.status)}
</Badge>
</div>
<span className="text-xs text-slate-500 dark:text-slate-400">
{appStatus.application.created_at
? `Applied ${relativeTime(appStatus.application.created_at)} · `
: ''}
application {appStatus.application.id}
</span>
{appStatus.events.length > 0 ? (
<ul className="flex flex-col gap-1.5">
{appStatus.events.map((event) => (
<li
key={event.id}
className="flex items-center justify-between gap-2 text-xs"
>
<span className="font-medium text-slate-700 dark:text-slate-300">
{applicationEventLabel(event.event_type)}
</span>
<span className="text-slate-500 dark:text-slate-400">
{relativeTime(event.created_at)}
</span>
</li>
))}
</ul>
) : (
<span className="text-xs text-slate-500 dark:text-slate-400">
No status updates yet.
</span>
)}
{appStatus.placement && (
<div
role="status"
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
>
<span className="font-semibold">Placement recorded</span>
<span className="text-xs">
{appStatus.placement.role_title} at {employerName} · starts{' '}
{appStatus.placement.start_date}
</span>
</div>
)}
{WITHDRAWABLE_STATUSES.has(appStatus.application.status) ? (
<Button
size="sm"
variant="outline"
type="button"
loading={withdrawing}
onClick={() => void onWithdraw()}
>
Withdraw application
</Button>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400">
{terminalCopy(appStatus.application.status)}
</p>
)}
{withdrawError && (
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
{withdrawError}
</p>
)}
</div>
) : confirmation ? (
<div
role="status"
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
@@ -457,3 +605,78 @@ export function JobDetailClient() {
function matchPercent(score: number): number {
return Math.max(0, Math.round(score * 100));
}
/** The seven D-099 states, honestly labeled (no stage invented beyond
* the matrix; unknown statuses render verbatim, never invented). */
function applicationStatusLabel(status: ApplicationStatus): string {
switch (status) {
case 'submitted':
return 'Submitted';
case 'in_review':
return 'In review';
case 'interview':
return 'Interview';
case 'offer':
return 'Offer';
case 'hired':
return 'Hired';
case 'rejected':
return 'Rejected';
case 'withdrawn':
return 'Withdrawn';
default:
return status;
}
}
/** Ledger event labels (the D-101 vocabulary) — unknown event names
* render verbatim, never invented. */
function applicationEventLabel(eventType: string): string {
switch (eventType) {
case 'application.in_review':
return 'Moved to review';
case 'application.interview':
return 'Interview stage';
case 'application.offer':
return 'Offer received';
case 'application.hired':
return 'Hired';
case 'application.rejected':
return 'Rejected';
case 'application.withdrawn':
return 'Withdrawn';
case 'placement.created':
return 'Placement recorded';
case 'placement.revoked':
return 'Placement revoked (operator correction)';
case 'placement.rehired':
return 'Placement re-recorded (operator correction)';
default:
return eventType;
}
}
/** The learner's legal withdraw sources (D-099: terminals have no
* outgoing edges). */
const WITHDRAWABLE_STATUSES: ReadonlySet<ApplicationStatus> = new Set([
'submitted',
'in_review',
'interview',
'offer',
]);
/** Terminal copy (the honest end states — no resurrection, no
* fabricated next step). */
function terminalCopy(status: ApplicationStatus): string {
switch (status) {
case 'hired':
return 'Hired — placement details appear above when a placement is recorded.';
case 'rejected':
return 'This application was not taken forward.';
case 'withdrawn':
return 'You withdrew this application.';
default:
return '';
}
}
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Check, Crown, Star, X, Zap } from 'lucide-react';
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
import type { Package, Plan } from '@nextcraft/types';
@@ -18,8 +19,12 @@ function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(0)}`;
}
/** CTA copy per D-079: the honest pilot path — operator-mediated
* checkout, no fake self-serve purchase buttons. */
/** P5 (REQ-10-005, Task 5-2-01 — superseding D-079): self-serve
* checkout is REAL. Purchasable tiers CTA to the employer-login path
* (the employer buys on their claimed entity's behalf from the
* billing surface); the ENTERPRISE contact tier stays "Contact Sales"
* (price_cents=0 rows are operator-mediated by design — the pin at
* pins.test.ts:250 holds). */
function ctaFor(id: string, isEnterprise: boolean): string {
if (isEnterprise) return 'Contact Sales';
if (id === 'pkg-bundle') return 'Buy Bundle';
@@ -94,9 +99,19 @@ function PricingCard({ item, kind }: { item: Package | Plan; kind: 'package' | '
))}
</ul>
<div className="mt-auto pt-2">
<Button type="button" variant={isPopular ? 'primary' : 'outline'} size="md" className="w-full">
{ctaFor(item.id, isEnterprise)}
</Button>
{/* P5 (Task 5-2-01): purchasable tiers route to the
* employer-login path (self-serve checkout is real — the
* employer buys from their billing surface); the enterprise
* contact tier keeps the Contact Sales path (operator-mediated
* by design). */}
<Link
href={isEnterprise ? '/marketplace/pricing#contact' : '/employer/login'}
className="block"
>
<Button type="button" variant={isPopular ? 'primary' : 'outline'} size="md" className="w-full">
{ctaFor(item.id, isEnterprise)}
</Button>
</Link>
</div>
</CardBody>
</Card>
@@ -195,8 +210,10 @@ export function PricingClient() {
</section>
<p className="text-sm text-slate-500 dark:text-slate-400">
Purchases are currently operator-mediated during the pilot — contact the Nextcraft
team to buy a package or plan for your employer account.
Self-serve checkout is live: sign in with your employer account and buy
packages or plans on your claimed entity&apos;s behalf from the billing
surface. Pilot payments run through the mock provider (honestly labeled
there); operator-assisted checkout remains available as a fallback.
</p>
</div>
);
+596 -1
View File
@@ -28,6 +28,32 @@ import type {
SearchResponse,
SearchResult,
CatalogResponse,
EmployerMe,
EmployerTokenResponse,
EmployerClaim,
OwnPostingsResponse,
RegisterEmployerRequest,
LoginEmployerRequest,
ClaimEmployerRequest,
EmployerPostingCreate,
EmployerPostingEdit,
EmployerPostingWriteResponse,
EmployerCheckoutCreate,
EmployerCheckoutResponse,
EmployerEntitlementResponse,
EmployerMockCompleteRequest,
EmployerMockCompleteResponse,
AdminAccountsResponse,
AdminClaimsResponse,
ClaimDecisionResponse,
AdminAuditResponse,
EmployerApplicationsResponse,
ApplicationTransitionResponse,
HireTransitionRequest,
EmployerPlacementsResponse,
LearnerApplicationStatus,
MineApplication,
PlacementOverview,
} from '@nextcraft/types';
import { engineBaseUrl } from './engine-base-url';
@@ -100,8 +126,38 @@ export class UpgradeRequiredError extends EngineError {
}
}
/** v0.10 (REQ-10-003): a 401 from an employer session route — the
* session is missing/expired/unknown. The honest logged-out state
* consumes this (a login CTA, NEVER a retry loop — R-61: no 401
* discrimination existed before the employer surface). */
export class SessionRequiredError extends EngineError {
constructor(message = 'Sign in to continue — your session is missing or expired.') {
super(message, 401);
this.name = 'SessionRequiredError';
}
}
/** A 403 carrying the unclaimed-account claim-CTA (D-090: 403 is the
* authorization class — a valid session whose account holds no claimed
* entity). The dashboard renders the claim prompt from it. */
export class ClaimRequiredError extends EngineError {
constructor(
public readonly reason: string,
public readonly claimCta: string,
) {
super('Claim your employer entity to continue.', 403);
this.name = 'ClaimRequiredError';
}
}
async function parseError(resp: Response): Promise<EngineError> {
if (resp.status === 503) return new EngineBusyError();
if (resp.status === 401) {
// v0.10: the 401 discrimination arm — an employer-session
// credential failure maps to SessionRequiredError so the surface
// renders the honest logged-out state (never a retry loop).
return new SessionRequiredError();
}
if (resp.status === 403) {
// G-10: discriminate 403 reasons — the payload shape decides.
// verify_cta present → identity gate (actionable enrollment prompt);
@@ -113,6 +169,13 @@ async function parseError(resp: Response): Promise<EngineError> {
/* non-JSON 403 body */
}
const detail = (payload as { detail?: unknown } | null)?.detail;
if (detail && typeof detail === 'object' && 'claim_cta' in (detail as object)) {
// v0.10 (D-090): the unclaimed-account 403 — a valid session
// whose account holds no claimed entity. The claim-CTA payload
// drives the dashboard's claim prompt.
const cta = detail as { reason: string; claim_cta: string };
return new ClaimRequiredError(cta.reason, cta.claim_cta);
}
if (detail && typeof detail === 'object' && 'verify_cta' in (detail as object)) {
const cta = detail as {
reason: string;
@@ -784,4 +847,536 @@ export async function getEntitlement(
return (await resp.json()) as EntitlementResponse;
}
// parseError gains the 402 discrimination (the dict-detail precedent).
// -- employer admin operator routes (v0.10 P5, REQ-10-005, D-094) -------------------
/** The G-308 admin transport over the SAME adminToken() wrapper: the
* operator token rides X-Admin-Token (never logged); 403 surfaces as
* NotAllowlistedError → the overview renders the honest locked state
* with the token prompt, never a retry loop (the entitlement-panel
* load() pattern, R-62). */
async function adminJsonFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = adminToken();
if (token) headers['X-Admin-Token'] = token;
const resp = await fetch(`${AI_SERVICE_URL}${path}`, { ...init, headers, signal: init?.signal });
if (!resp.ok) throw await parseError(resp);
return (await resp.json()) as T;
}
/** GET /v1/employers/admin/accounts — the employer accounts list
* (D-094): email, claimed entity, created_at, the UNVERIFIED marker
* (D-097 — displayed as such, never as verified). password_hash never
* rides the wire (D-087). */
export async function listEmployerAdminAccounts(
signal?: AbortSignal,
): Promise<AdminAccountsResponse> {
return adminJsonFetch<AdminAccountsResponse>('/v1/employers/admin/accounts', { signal });
}
/** GET /v1/employers/admin/claims — the claim queue (D-089): status,
* the stored claim-time evidence snapshot (the domain-match signal is
* ADVISORY, never verification — A-304), the decided state. Optional
* status facet rides ?status=. */
export async function listEmployerAdminClaims(
signal?: AbortSignal,
status?: string,
): Promise<AdminClaimsResponse> {
const suffix = status ? `?status=${encodeURIComponent(status)}` : '';
return adminJsonFetch<AdminClaimsResponse>(`/v1/employers/admin/claims${suffix}`, { signal });
}
/** POST /v1/employers/admin/claims/{id}/approve — the EXPLICIT
* operator decision: activates ownership (one account : one entity),
* stamps provenance, appends the audit event in the same transaction.
* Evidence never auto-activates anything. */
export async function approveEmployerAdminClaim(
claimId: string,
signal?: AbortSignal,
): Promise<EmployerClaim> {
const body = await adminJsonFetch<ClaimDecisionResponse>(
`/v1/employers/admin/claims/${encodeURIComponent(claimId)}/approve`,
{ method: 'POST', signal },
);
return body.claim;
}
/** POST /v1/employers/admin/claims/{id}/reject — the other explicit
* operator arm: the entity is freed for a new claim (the rejected row
* never touches the pending index, G-316). */
export async function rejectEmployerAdminClaim(
claimId: string,
signal?: AbortSignal,
): Promise<EmployerClaim> {
const body = await adminJsonFetch<ClaimDecisionResponse>(
`/v1/employers/admin/claims/${encodeURIComponent(claimId)}/reject`,
{ method: 'POST', signal },
);
return body.claim;
}
/** GET /v1/employers/admin/audit — the queryable operator trail: every
* claim decision with operator provenance (D-089's answerability). */
export async function listEmployerAdminAudit(
signal?: AbortSignal,
): Promise<AdminAuditResponse> {
return adminJsonFetch<AdminAuditResponse>('/v1/employers/admin/audit', { signal });
}
// -- employer auth + account (v0.10 P3, REQ-10-003, D-092) -------------------------
/** The nc-employer-session sessionStorage key (the adminToken()
* pattern, R-61): tab-scoped BY DESIGN — the session dies with the
* tab, which bounds the token's XSS exposure window (open-risk-4,
* documented in the dashboard copy). Only the raw token rides here;
* the server stores only sha256(token) (D-087). */
export const EMPLOYER_SESSION_KEY = 'nc-employer-session';
/** Window-guarded read (the adminToken() discipline: no window, no
* storage — the static-export prerender path). */
export function employerSessionToken(): string {
if (typeof window === 'undefined') return '';
return window.sessionStorage.getItem(EMPLOYER_SESSION_KEY) ?? '';
}
export function setEmployerSessionToken(token: string): void {
if (typeof window === 'undefined') return;
window.sessionStorage.setItem(EMPLOYER_SESSION_KEY, token);
}
export function clearEmployerSessionToken(): void {
if (typeof window === 'undefined') return;
window.sessionStorage.removeItem(EMPLOYER_SESSION_KEY);
}
/** The Authorization header on every employer fetch: single-header
* Bearer transport (D-090 — the require_employer_session shape). A
* missing token still fetches (the route's honest 401 → the surface's
* logged-out state); the token is never logged anywhere. */
function employerHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = employerSessionToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
/** POST /v1/employers/auth/register — email+password → account
* (UNVERIFIED, D-097) + a fresh session (registration logs in). */
export async function registerEmployer(
body: RegisterEmployerRequest,
signal?: AbortSignal,
): Promise<EmployerTokenResponse> {
return jsonFetch<EmployerTokenResponse>('/v1/employers/auth/register', {
method: 'POST',
body: JSON.stringify(body),
signal,
});
}
/** POST /v1/employers/auth/login — bad credentials → SessionRequiredError
* (the 401 arm; no account-enumeration difference server-side). */
export async function loginEmployer(
body: LoginEmployerRequest,
signal?: AbortSignal,
): Promise<EmployerTokenResponse> {
return jsonFetch<EmployerTokenResponse>('/v1/employers/auth/login', {
method: 'POST',
body: JSON.stringify(body),
signal,
});
}
/** POST /v1/employers/auth/logout — revokes the session server-side
* AND clears the tab-scoped storage (both arms, always): a revoke
* failure on an already-dead token still leaves the caller logged-out
* locally, never holding a zombie session. */
export async function logoutEmployer(signal?: AbortSignal): Promise<void> {
const headers = employerHeaders(); // built BEFORE the clear — the
// dying token still authenticates the revoke call.
clearEmployerSessionToken();
if (!headers['Authorization']) return; // nothing to revoke — storage already clean
const resp = await fetch(`${AI_SERVICE_URL}/v1/employers/auth/logout`, {
method: 'POST',
headers,
signal,
});
if (!resp.ok) {
// A 401 here means the token was already dead — the caller is
// logged out either way; the honest state is logged-out, not an
// error surface. Other failures surface as-is.
const err = await parseError(resp);
if (!(err instanceof SessionRequiredError)) throw err;
}
}
/** GET /v1/employers/auth/me — the session's account + claim state;
* missing/expired session → SessionRequiredError (401 arm). */
export async function getEmployerMe(signal?: AbortSignal): Promise<EmployerMe> {
return jsonFetch<EmployerMe>('/v1/employers/auth/me', {
headers: employerHeaders(),
signal,
});
}
/** POST /v1/employers/claims — claim an existing EmployerRow entity;
* the response is the pending claim with its claim-time evidence
* snapshot (advisory, D-089 — auto-approval is deferred). */
export async function claimEmployerEntity(
body: ClaimEmployerRequest,
signal?: AbortSignal,
): Promise<EmployerClaim> {
return jsonFetch<EmployerClaim>('/v1/employers/claims', {
method: 'POST',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
});
}
/** GET /v1/employers/postings — the claimed employer's OWN postings
* (draft|published|closed lifecycle + per-posting applicant counts +
* facets). Unclaimed account → ClaimRequiredError (the 403 claim-CTA
* discrimination, D-090); expired session → SessionRequiredError. */
export async function listOwnEmployerPostings(
signal?: AbortSignal,
): Promise<OwnPostingsResponse> {
return jsonFetch<OwnPostingsResponse>('/v1/employers/postings', {
headers: employerHeaders(),
signal,
});
}
// -- employer own-postings write (v0.10 P4, REQ-10-004, G-320b) --------------------
/** POST /v1/employers/postings — create a DRAFT on the claimed
* entity. Gate order server-side (G-311): expired session →
* SessionRequiredError; unclaimed → ClaimRequiredError; a 402 →
* UpgradeRequiredError (the shared D-091 gate's reason + upgrade_cta
* ONLY — the payload's posts_used/posts_limit are byte-stable zeros
* and are NEVER rendered, G-320b; real limits render exclusively from
* the entitlement view). */
export async function createOwnEmployerPosting(
body: EmployerPostingCreate,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>('/v1/employers/postings', {
method: 'POST',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
});
}
/** PATCH /v1/employers/postings/{id} — edit the OWN DRAFT's content
* (G-318: published/closed → 409 honest own-drafts-only copy; another
* entity's posting → 403 the honest authorization error; unknown →
* 404 honest). */
export async function editOwnEmployerPosting(
postingId: string,
body: EmployerPostingEdit,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>(
`/v1/employers/postings/${encodeURIComponent(postingId)}`,
{
method: 'PATCH',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
},
);
}
/** POST /v1/employers/postings/{id}/publish — own draft → published.
* 409 on a non-draft, 403 cross-entity, 404 unknown (all honest). */
export async function publishOwnEmployerPosting(
postingId: string,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>(
`/v1/employers/postings/${encodeURIComponent(postingId)}/publish`,
{
method: 'POST',
headers: employerHeaders(),
signal,
},
);
}
/** POST /v1/employers/postings/{id}/close — own posting → closed
* (published → closed; 409 on closing a draft or re-closing). */
export async function closeOwnEmployerPosting(
postingId: string,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>(
`/v1/employers/postings/${encodeURIComponent(postingId)}/close`,
{
method: 'POST',
headers: employerHeaders(),
signal,
},
);
}
// -- employer self-serve billing (v0.10 P5, REQ-10-005) ---------------------------
/** POST /v1/employers/billing/checkout — self-serve checkout over the
* v0.9 catalog (the buyer derives from the session's CLAIMED entity,
* D-076 superseded). Unclaimed account → ClaimRequiredError (the 403
* claim-CTA, D-090); unknown catalog item → 404 honest; expired
* session → SessionRequiredError. The response carries the provider
* session id — the in-app mock-complete arm (pilot, provider=mock
* only) consumes it; the url is the provider's checkout pointer
* (never fetched under the mock provider). */
export async function startOwnEmployerCheckout(
body: EmployerCheckoutCreate,
signal?: AbortSignal,
): Promise<EmployerCheckoutResponse> {
return jsonFetch<EmployerCheckoutResponse>('/v1/employers/billing/checkout', {
method: 'POST',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
});
}
/** GET /v1/employers/billing/entitlement — the session employer's OWN
* entitlement summary (current package, posts used/limit, expiry —
* the v0.9 shapes resolved for the claimed entity). Honest states:
* purchase/subscription null until a real grant exists; expired
* windows are skipped lazily server-side (G-306) — the empty/expired
* arms render from THIS response, never from the 402 payload's
* byte-stable zeros (G-320b). */
export async function getOwnEmployerEntitlement(
signal?: AbortSignal,
): Promise<EmployerEntitlementResponse> {
return jsonFetch<EmployerEntitlementResponse>('/v1/employers/billing/entitlement', {
headers: employerHeaders(),
signal,
});
}
/** POST /v1/employers/billing/mock/complete — the HONEST pilot
* completion arm (D-082 transposed): employer-session-gated, mock-only
* via the route's isinstance gate (the router mounts whole — 404 under
* stripe is an honest absence, not a fake success). The
* provider_session_id resolves from the session's OWN checkout rows
* (cross-session completion → 403, the G-301 class); the completion
* mints a signed event through the REAL webhook path — the grant is
* real, only the payment is mock. */
export async function completeOwnEmployerMockCheckout(
body: EmployerMockCompleteRequest,
signal?: AbortSignal,
): Promise<EmployerMockCompleteResponse> {
return jsonFetch<EmployerMockCompleteResponse>(
'/v1/employers/billing/mock/complete',
{
method: 'POST',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
},
);
}
// -- placements (v0.11 P3, REQ-11-002/003, D-110) ----------------------------------
/** GET /v1/employers/applications — the session employer's OWN hiring
* pipeline (every status, per-status facets). Expired session →
* SessionRequiredError; unclaimed → ClaimRequiredError (the G-311
* gates surface as the honest state classes, never a re-fetch). */
export async function listOwnEmployerApplications(
signal?: AbortSignal,
): Promise<EmployerApplicationsResponse> {
return jsonFetch<EmployerApplicationsResponse>('/v1/employers/applications', {
headers: employerHeaders(),
signal,
});
}
/** The shared per-target transition transport (D-105: the matrix stays
* OUT of the request bodies — each call names its target). 401 →
* SessionRequiredError; 403-claim → ClaimRequiredError; 403
* cross-entity + 409 matrix → EngineError with the honest status
* (the surfaces render the state from err.status — NO new error
* classes, D-110). */
async function employerTransition(
applicationId: string,
target: 'in-review' | 'interview' | 'offer' | 'reject' | 'hire',
body?: HireTransitionRequest,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return jsonFetch<ApplicationTransitionResponse>(
`/v1/employers/applications/${encodeURIComponent(applicationId)}/${target}`,
{
method: 'POST',
headers: employerHeaders(),
body: body === undefined ? undefined : JSON.stringify(body),
signal,
},
);
}
/** POST .../in-review — submitted → in_review. */
export function employerMarkInReview(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'in-review', undefined, signal);
}
/** POST .../interview. */
export function employerMarkInterview(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'interview', undefined, signal);
}
/** POST .../offer. */
export function employerMarkOffer(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'offer', undefined, signal);
}
/** POST .../reject — terminal. */
export function employerRejectApplication(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'reject', undefined, signal);
}
/** POST .../hire — terminal; carries the employer-attested
* {role_title, start_date} (D-103/G-324). The response carries the
* created placement record. */
export function employerHireApplication(
applicationId: string,
body: HireTransitionRequest,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'hire', body, signal);
}
/** GET /v1/employers/placements — the claimed entity's OWN placements
* (active + revoked, the full history; the overview's placements
* section data source, D-109 surface 2). */
export async function listOwnEmployerPlacements(
signal?: AbortSignal,
): Promise<EmployerPlacementsResponse> {
return jsonFetch<EmployerPlacementsResponse>('/v1/employers/placements', {
headers: employerHeaders(),
signal,
});
}
// -- learner placement status (v0.11 P5, REQ-11-005, D-104/G-322) -------------------
/** GET /v1/marketplace/applications/mine?posting_id=&learner_id= —
* G-322 (binding) discovery: the status panel finds the application by
* the (posting, learner) pair — NEVER a client-persisted
* application_id. 404 = no application yet (the surface renders the
* apply CTA — honest absence). */
export async function getMyApplication(
postingId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<MineApplication> {
return jsonFetch<MineApplication>(
`/v1/marketplace/applications/mine?posting_id=${encodeURIComponent(postingId)}&learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
}
/** GET /v1/marketplace/applications/{application_id}?learner_id= —
* D-104: the learner's-own outcome read — current status + the full
* id-ordered timestamped history + the placement record when hired
* (one read, one truth source). 403 foreign → EngineError (the
* surface renders the honest "not your application" state from
* err.status — NO new error classes, D-110). */
export async function getLearnerApplicationStatus(
applicationId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<LearnerApplicationStatus> {
return jsonFetch<LearnerApplicationStatus>(
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}?learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
}
/** POST /v1/marketplace/applications/{application_id}/withdraw — the
* learner's ONLY edit (D-099/D-104); terminal → 409 (the matrix
* surfaces as EngineError.status — the panel renders the honest
* terminal state, never a re-fetch). */
export async function withdrawApplication(
applicationId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<ApplicationConfirmation> {
return jsonFetch<ApplicationConfirmation>(
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}/withdraw`,
{
method: 'POST',
body: JSON.stringify({ learner_id: learnerId }),
signal,
},
);
}
// -- admin placements (v0.11 P3/P4, REQ-11-003/004, D-107/D-108) --------------------
/** GET /v1/placements/admin/overview — the D-107 computation over the
* G-308 admin transport: distinct learners toward the 1,000 guiding
* goal, active placements, revoked count, per-employer + per-cohort
* month breakdowns. G-328: the caller labels these as RECORDED
* placements — never a verification claim. */
export async function getPlacementOverview(
signal?: AbortSignal,
): Promise<PlacementOverview> {
return adminJsonFetch<PlacementOverview>('/v1/placements/admin/overview', {
signal,
});
}
/** GET /v1/placements/admin/export — the self-contained audit JSON
* (D-108: cohort + evidence bundles + methodology + the ledger with
* head_hash). The caller downloads the document as a JSON blob. */
export async function getPlacementExport(signal?: AbortSignal): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
/** GET /v1/placements/admin/export/summary — the human-readable
* markdown rendering FROM the same document (D-108's anti-drift
* rule); opens in-tab. */
export async function getPlacementExportSummary(
signal?: AbortSignal,
): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export/summary`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
@@ -0,0 +1,381 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P5 (REQ-10-005, Task 5-2-02 — D-094/R-62): the admin network
* overview. Fetch stubs pin the operator-route transport (the admin
* token rides X-Admin-Token, the approve/reject dispatch, the 403
* locked-state discrimination); source pins cover the overview's
* locked state, the A-304 advisory evidence rendering, and the
* accounts-list columns (no render harness in-tree — the D-059/G-312
* pin discipline).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrappers guard on window).
* location included: engineBaseUrl reads window.location.hostname at
* module-import time (the auth-pages precedent). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
location: { hostname: "localhost" },
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const ACCOUNTS = {
accounts: [
{
id: "acct-1",
email: "ops@example.com",
employer_id: "emp-openai",
created_at: "2026-09-30T00:00:00Z",
email_verified: false,
},
],
count: 1,
};
const CLAIMS = {
claims: [
{
id: "claim-1",
account_id: "acct-1",
employer_id: "emp-anthropic",
status: "pending",
evidence: {
account_email_domain: "example.com",
entity_website_domain: "example.com",
match: false,
},
created_at: "2026-09-30T00:00:00Z",
decided_by: null,
decided_at: null,
},
],
count: 1,
};
const AUDIT = {
events: [
{
id: 1,
account_id: "acct-1",
employer_id: "emp-anthropic",
kind: "claim",
name: "claim.submitted",
detail: { claim_id: "claim-1" },
operator: null,
created_at: "2026-09-30T00:00:00Z",
},
],
count: 1,
};
test("listEmployerAdminAccounts: rides X-Admin-Token + GETs the accounts route", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify(ACCOUNTS), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("op-token-1");
const accounts = await mod.listEmployerAdminAccounts();
assert.equal(accounts.count, 1);
assert.equal(accounts.accounts[0]?.employer_id, "emp-openai");
assert.equal(
new URL(calls[0].url, "http://localhost").pathname,
"/v1/employers/admin/accounts",
);
assert.equal(
calls[0].headers["X-Admin-Token"],
"op-token-1",
"G-308: the operator token rides the header",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("listEmployerAdminClaims: GETs the queue; the status facet rides ?status=", async () => {
const realFetch = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: unknown) => {
calls.push(String(input));
return new Response(JSON.stringify(CLAIMS), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("op-token-1");
const queue = await mod.listEmployerAdminClaims();
assert.equal(queue.claims[0]?.status, "pending");
assert.equal(queue.claims[0]?.evidence.match, false, "the advisory signal rides the evidence");
assert.equal(
new URL(calls[0], "http://localhost").pathname,
"/v1/employers/admin/claims",
);
await mod.listEmployerAdminClaims(undefined, "pending");
assert.ok(
new URL(calls[1], "http://localhost").searchParams.get("status") === "pending",
"the status facet rides the query",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("approve/reject dispatch: POST the decision sub-routes through the admin transport", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify({ claim: CLAIMS.claims[0] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("op-token-1");
const approved = await mod.approveEmployerAdminClaim("claim-1");
assert.equal(approved.id, "claim-1", "the decision response carries the decided claim");
const rejected = await mod.rejectEmployerAdminClaim("claim-1");
assert.equal(rejected.id, "claim-1");
assert.equal(
new URL(calls[0].url, "http://localhost").pathname,
"/v1/employers/admin/claims/claim-1/approve",
);
assert.equal(calls[0].method, "POST", "approve is a POST");
assert.equal(
new URL(calls[1].url, "http://localhost").pathname,
"/v1/employers/admin/claims/claim-1/reject",
);
assert.equal(calls[1].method, "POST", "reject is a POST");
assert.equal(calls[0].headers["X-Admin-Token"], "op-token-1");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("listEmployerAdminAudit: GETs the audit trail", async () => {
const realFetch = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: unknown) => {
calls.push(String(input));
return new Response(JSON.stringify(AUDIT), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("op-token-1");
const trail = await mod.listEmployerAdminAudit();
assert.equal(trail.events[0]?.name, "claim.submitted");
assert.equal(
new URL(calls[0], "http://localhost").pathname,
"/v1/employers/admin/audit",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("403 on the operator routes → the honest locked-state discrimination (fail-closed, R-62)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ detail: "admin token required" }), {
status: 403,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("wrong-token");
for (const fn of [
mod.listEmployerAdminAccounts,
mod.listEmployerAdminClaims,
mod.listEmployerAdminAudit,
]) {
await assert.rejects(
fn(),
(err: unknown) => {
const e = err as InstanceType<typeof mod.EngineError>;
return e instanceof mod.EngineError && e.status === 403;
},
"the fail-closed 403 surfaces with its status",
);
}
await assert.rejects(
mod.approveEmployerAdminClaim("claim-1"),
(err: unknown) => {
const e = err as InstanceType<typeof mod.EngineError>;
return e instanceof mod.EngineError && e.status === 403;
},
"approve is locked too (all five routes fail-closed)",
);
await assert.rejects(
mod.rejectEmployerAdminClaim("claim-1"),
(err: unknown) => {
const e = err as InstanceType<typeof mod.EngineError>;
return e instanceof mod.EngineError && e.status === 403;
},
"reject is locked too",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("pin: the network overview carries the R-62 locked-state design (the entitlement-panel load() pattern)", async () => {
const src = readFileSync(
web("components/admin/employer-network-panel.tsx"),
"utf8",
);
// Token input + sessionStorage transport (never logged).
assert.ok(/type="password"/.test(src), "token input is masked");
assert.ok(/setAdminToken/.test(src), "sessionStorage transport");
assert.ok(/status === 403/.test(src), "403 discriminates the locked state");
assert.ok(/Locked — the admin token is missing or invalid/.test(src), "honest locked state with the token prompt");
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), "no retry loop machinery (never a retry loop)");
assert.ok(!/console\./.test(src), "no console logging");
// An empty token never dispatches (the explicit-load design).
assert.ok(/if \(!trimmed\) return;/.test(src), "empty token never dispatches");
// The operator functions feed the panel through the wrapper.
for (const fn of [
"listEmployerAdminAccounts",
"listEmployerAdminClaims",
"listEmployerAdminAudit",
"approveEmployerAdminClaim",
"rejectEmployerAdminClaim",
]) {
assert.ok(src.includes(fn), `operator route wired: ${fn}`);
}
// The panel never touches the header directly (G-308).
assert.ok(!/X-Admin-Token/.test(src), "the panel goes through the wrapper");
});
test("pin: the claim queue renders the evidence ADVISORY — never verification (A-304, MH-5e)", async () => {
const src = readFileSync(
web("components/admin/employer-network-panel.tsx"),
"utf8",
);
// The A-304 label rides the evidence block.
assert.ok(
/Advisory evidence \(not verification/.test(src),
"the evidence renders with the advisory label",
);
assert.ok(
/the operator decision activates/.test(src),
"the copy names what activates (never the signal)",
);
// The evidence fields render (the D-089 snapshot shape).
assert.ok(/account_email_domain/.test(src), "email domain renders");
assert.ok(/entity_website_domain/.test(src), "website domain renders");
assert.ok(/domain-match signal/.test(src), "the match signal renders as a signal");
// The signal is never presented as verification.
assert.ok(!/Verified|verif(?:ied|ication) (?:match|signal)/.test(src), "no verification language on the signal");
// A missing snapshot renders unavailable — never a fabricated match.
assert.ok(/unavailable/.test(src), "missing evidence renders honestly");
// The explicit operator actions exist on pending claims.
assert.ok(/Approve claim/.test(src), "approve action");
assert.ok(/Reject claim/.test(src), "reject action");
assert.ok(/'pending'/.test(src), "actions render on pending claims only");
// Honest 404/409 decision states (the constraint 409s are honest).
assert.ok(/no longer exists/.test(src), "honest 404 decision copy");
assert.ok(/err instanceof EngineError && err\.status === 409/.test(src), "the 409 arm surfaces the constraint honestly");
// No optimistic updates: the queue re-reads after decisions.
assert.ok(/listEmployerAdminClaims/.test(src.slice(src.indexOf("const decide"))), "decisions re-read the queue");
});
test("pin: the accounts list renders the D-094 columns; the moderation page gains the sibling sections BELOW the v0.9 panel", async () => {
const src = readFileSync(
web("components/admin/employer-network-panel.tsx"),
"utf8",
);
// Accounts columns: email, claimed entity, created_at (+ the
// UNVERIFIED marker rendered as such, D-097).
assert.ok(/Email/.test(src), "email column");
assert.ok(/Claimed entity/.test(src), "claimed entity column");
assert.ok(/Created/.test(src), "created column");
assert.ok(/Unverified/.test(src), "the unverified marker renders as such");
assert.ok(!/email_verified === true|Verified</.test(src), "never rendered as verified");
// Honest empty states.
assert.ok(/No employer accounts yet/.test(src), "accounts empty state");
assert.ok(/No claims in the queue/.test(src), "queue empty state");
assert.ok(/No audit events yet/.test(src), "audit empty state");
// The audit view renders the provenance columns.
assert.ok(/Operator/.test(src), "operator provenance column");
assert.ok(/decided by/.test(src), "decided_by provenance renders on claims");
// The moderation page: the network overview lands BELOW the v0.9
// entitlement panel (sibling sections, D-094).
const page = readFileSync(web("app/(admin)/admin/moderation/page.tsx"), "utf8");
const entitlementIdx = page.indexOf("<EmployerEntitlementPanel");
const networkIdx = page.indexOf("<EmployerNetworkPanel");
assert.ok(entitlementIdx > 0, "the v0.9 entitlement panel is retained");
assert.ok(networkIdx > entitlementIdx, "the network overview renders BELOW the entitlement panel");
assert.ok(/<section/.test(page), "sibling section blocks");
});
test("pin: the engine-client admin section joins the no-logging discipline (G-308)", async () => {
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
// The admin transport lives beside getEntitlement in the ONE client
// module (no fork), rides adminToken(), and never logs.
const adminSection = client.slice(client.indexOf("-- employer admin operator routes"));
assert.ok(adminSection.includes("adminJsonFetch"), "the shared admin transport");
assert.ok(adminSection.includes("adminToken()"), "over the existing adminToken() wrapper");
assert.ok(!/console\./.test(adminSection), "no console logging in the admin section");
// Every operator function takes an AbortSignal (A-406).
for (const fn of [
"listEmployerAdminAccounts",
"listEmployerAdminClaims",
"approveEmployerAdminClaim",
"rejectEmployerAdminClaim",
"listEmployerAdminAudit",
]) {
assert.ok(
new RegExp(`export async function ${fn}\\([\\s\\S]*?signal\\?`).test(client),
`${fn} takes an AbortSignal`,
);
}
});
+165
View File
@@ -0,0 +1,165 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P3 (REQ-10-003, MH-3e): the /employer/login + /employer/register
* submit flows. Per G-312 (the D-059/E87 discipline): fetch-stub tests
* over the page-level submit functions the clients call + SOURCE pins
* for the honest-state branches — no jsdom/happy-dom render harness
* exists in-tree and none may be added this milestone.
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrapper guards on window). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
location: { hostname: "localhost" }, // engineBaseUrl reads this under window
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const TOKEN_BODY = { token: "tok-login-1", expires_at: "2026-10-07T00:00:00Z" };
test("login handoff: a successful login POST stores the session token (register→login handoff arm)", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(JSON.stringify(TOKEN_BODY), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
const session = await mod.loginEmployer(
{ email: "ops@example.com", password: "longenough1" },
undefined,
);
mod.setEmployerSessionToken(session.token);
assert.equal(store.get("nc-employer-session"), "tok-login-1");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/login");
assert.ok(calls[0].body?.includes('"password":"longenough1"'), "the login body carries the credential");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("register handoff: register → session token stored → dashboard (the success path lands logged-in)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify(TOKEN_BODY), {
status: 201,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
const session = await mod.registerEmployer({
email: "new@example.com",
password: "longenough1",
});
mod.setEmployerSessionToken(session.token);
assert.equal(store.get("nc-employer-session"), "tok-login-1");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("honest states: 401/409/429/422 map to the pages' error copy branches (fetch-stub discrimination)", async () => {
const { SessionRequiredError, RateLimitedError, EngineError } = await import(
`../lib/engine-client.ts${QS}`
);
// 401 → the login page's invalid-credentials branch.
assert.ok(new SessionRequiredError() instanceof EngineError);
assert.equal(new SessionRequiredError().status, 401);
// 429 → the throttle-wait branch.
assert.ok(new RateLimitedError() instanceof EngineError);
assert.equal(new RateLimitedError().status, 429);
// 409/422 ride the generic EngineError with their statuses intact —
// the pages' copy branches discriminate on err.status.
assert.equal(new EngineError("an account with this email already exists", 409).status, 409);
assert.equal(new EngineError("invalid", 422).status, 422);
});
test("pin: the login/register pages + clients keep the honest-state + no-password-echo discipline (G-312 source pins)", async () => {
const loginPage = readFileSync(web("app/(employer)/employer/login/page.tsx"), "utf8");
const registerPage = readFileSync(web("app/(employer)/employer/register/page.tsx"), "utf8");
const loginClient = readFileSync(web("components/employer/login-client.tsx"), "utf8");
const registerClient = readFileSync(web("components/employer/register-client.tsx"), "utf8");
// Server shells with metadata (A-411) rendering the clients (A-406).
for (const [name, page] of [
["login", loginPage],
["register", registerPage],
] as const) {
assert.ok(/export const metadata/.test(page), `${name}: build-time metadata`);
assert.ok(!/"use client"/.test(page), `${name}: server shell (client lives in the component)`);
assert.ok(/<LoginClient \/>|<RegisterClient \/>/.test(page), `${name}: renders the client`);
}
assert.ok(/'use client';/.test(loginClient), "login client is a client component");
assert.ok(/'use client';/.test(registerClient), "register client is a client component");
// AbortController + honest loading/error states (A-406).
for (const [name, client] of [
["login", loginClient],
["register", registerClient],
] as const) {
assert.ok(/AbortController/.test(client), `${name}: AbortController discipline`);
assert.ok(/abortRef\.current\?\.abort\(\)/.test(client), `${name}: in-flight abort`);
assert.ok(/loading/i.test(client), `${name}: loading state`);
assert.ok(/error/i.test(client), `${name}: error state`);
assert.ok(/controller\.signal\.aborted\) return;/.test(client), `${name}: aborted responses never render`);
}
// The honest error-copy branches exist, per status, in both clients.
assert.ok(/SessionRequiredError/.test(loginClient), "login: 401 branch");
assert.ok(/RateLimitedError/.test(loginClient), "login: 429 branch");
assert.ok(/err\.status === 422/.test(loginClient), "login: 422 branch");
assert.ok(/SessionRequiredError/.test(registerClient), "register: session branch");
assert.ok(/RateLimitedError/.test(registerClient), "register: 429 branch");
assert.ok(/err\.status === 409/.test(registerClient), "register: 409 duplicate-email branch");
assert.ok(/err\.status === 422/.test(registerClient), "register: 422 branch");
// 422 copy never echoes the password: no template interpolation of
// the password into any message + no console logging of it anywhere.
for (const [name, client] of [
["login", loginClient],
["register", registerClient],
] as const) {
assert.ok(!/\$\{password\}/.test(client), `${name}: no password interpolation`);
assert.ok(!/console\./.test(client), `${name}: no console logging`);
}
// Success stores the session + redirects to the dashboard.
assert.ok(/setEmployerSessionToken\(session\.token\)/.test(loginClient), "login: stores the session");
assert.ok(/setEmployerSessionToken\(session\.token\)/.test(registerClient), "register: stores the session");
assert.ok(/router\.push\('\/employer'\)/.test(loginClient), "login: redirects to the dashboard");
assert.ok(/router\.push\('\/employer'\)/.test(registerClient), "register: lands logged-in on the dashboard");
// The register page's copy is honest about UNVERIFIED email (D-097).
assert.ok(/unverified/i.test(registerPage), "register copy: email marked unverified");
});
+346
View File
@@ -0,0 +1,346 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P5 (REQ-10-005, Task 5-2-01 — the employer billing surface):
* fetch stubs pin the engine-client billing functions (checkout
* dispatch, entitlement wire, mock-complete confirmation) + source
* pins cover the billing surface's honest states and the mock-path
* labeling (no render harness in-tree — the D-059/G-312 pin
* discipline).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrappers guard on window).
* location included: engineBaseUrl reads window.location.hostname at
* module-import time (the auth-pages precedent). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
location: { hostname: "localhost" },
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const ENTITLEMENT_ACTIVE = {
employer_id: "emp-openai",
purchase: {
id: "pur-1",
package_id: "pkg-bundle",
package_name: "Bundle",
posts_used: 3,
posts_limit: 10,
expires_at: "2026-12-28T00:00:00Z",
},
subscription: null,
};
const ENTITLEMENT_EMPTY = {
employer_id: "emp-openai",
purchase: null,
subscription: null,
};
const CHECKOUT_RESPONSE = {
session_id: "cs-1",
provider_session_id: "mock-sess-1",
url: "https://mock.local/checkout/mock-sess-1",
mode: "payment",
};
test("startOwnEmployerCheckout: POSTs the checkout route with the Bearer token + item_id on the wire", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string>; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(JSON.stringify(CHECKOUT_RESPONSE), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-buy");
const created = await mod.startOwnEmployerCheckout({ item_id: "pkg-bundle" });
assert.equal(created.session_id, "cs-1");
assert.equal(created.provider_session_id, "mock-sess-1");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/billing/checkout");
assert.equal(calls[0].method, "POST", "checkout is a POST");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-buy", "the Bearer token rides checkout");
assert.ok(calls[0].body?.includes('"item_id":"pkg-bundle"'), "item_id rides the body");
// The buyer derives from the session's claim — employer_id never
// rides the body (D-076 superseded).
assert.ok(!calls[0].body?.includes("employer_id"), "no employer_id in the checkout body");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("getOwnEmployerEntitlement: GETs the own-entitlement route with the Bearer token (wire shape: honest nulls)", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify(ENTITLEMENT_ACTIVE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-ent");
const entitlement = await mod.getOwnEmployerEntitlement();
assert.equal(entitlement.purchase?.posts_used, 3);
assert.equal(entitlement.purchase?.posts_limit, 10);
assert.equal(entitlement.subscription, null, "honest null subscription");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/billing/entitlement");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-ent");
// The empty state rides the same shape (honest nulls — the surface
// renders the empty/expired arms from THIS).
const empty = await mod.getOwnEmployerEntitlement();
void empty;
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("completeOwnEmployerMockCheckout: POSTs the mock-complete route with the untrusted provider_session_id reference", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string>; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(JSON.stringify({ completed: true, payment_status: "paid" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-mock");
const result = await mod.completeOwnEmployerMockCheckout({
provider_session_id: "mock-sess-1",
});
assert.equal(result.completed, true);
assert.equal(result.payment_status, "paid");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/billing/mock/complete");
assert.equal(calls[0].method, "POST", "mock-complete is a POST");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-mock");
assert.ok(
calls[0].body?.includes('"provider_session_id":"mock-sess-1"'),
"the untrusted ID reference rides the body (G-302)",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("401 on billing routes → SessionRequiredError (the logged-out state, never a retry loop)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ detail: "invalid or expired session" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(
mod.getOwnEmployerEntitlement(),
(err: unknown) => err instanceof mod.SessionRequiredError,
"401 maps to SessionRequiredError on the entitlement route",
);
await assert.rejects(
mod.startOwnEmployerCheckout({ item_id: "pkg-single" }),
(err: unknown) => err instanceof mod.SessionRequiredError,
"401 maps to SessionRequiredError on the checkout route",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("403 claim-CTA on the checkout route → ClaimRequiredError (no buyer without a claim)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
reason: "no_claimed_employer",
claim_cta: "This account has not claimed an employer entity yet — submit a claim via POST /v1/employers/claims.",
},
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(
mod.startOwnEmployerCheckout({ item_id: "pkg-single" }),
(err: unknown) => err instanceof mod.ClaimRequiredError,
"the unclaimed 403 maps to ClaimRequiredError on checkout",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("404 on mock-complete (stripe-mode deployment) → the honest EngineError, never a fake success", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({ detail: "mock-complete exists only under provider=mock" }),
{ status: 404, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(
mod.completeOwnEmployerMockCheckout({ provider_session_id: "sess-x" }),
(err: unknown) => {
const e = err as InstanceType<typeof mod.EngineError>;
return e instanceof mod.EngineError && e.status === 404;
},
"stripe-mode mock-complete 404s honestly",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("pin: the billing surface renders the entitlement view + buy flow + honest mock-complete labeling (G-312 source pins)", async () => {
const src = readFileSync(
web("components/employer/employer-billing-client.tsx"),
"utf8",
);
// Entitlement view: the ONLY place real limits render (G-320b).
assert.ok(/getOwnEmployerEntitlement/.test(src), "the entitlement route feeds the view");
assert.ok(/posts_used/.test(src) && /posts_limit/.test(src), "posts used/limit render");
assert.ok(/expires_at/.test(src), "expiry renders");
// Honest empty state (MH-5c): a null purchase NEVER asserts expiry
// provenance — the wire shape cannot distinguish never-purchased
// from expired (G-306 lazy skip reads both as null), so the surface
// renders the same honest empty copy for both.
assert.ok(/No active posting package/.test(src), "honest empty state");
assert.ok(!/previous purchases have expired/.test(src), "never fabricates expiry provenance from a null purchase");
assert.ok(!/const expired/.test(src), "no expired-branch derived from the null purchase");
// The buy flow: the PUBLIC v0.9 catalog REUSED (listBillingCatalog,
// never a duplicate catalog fetch path).
assert.ok(/listBillingCatalog/.test(src), "the catalog comes from the public v0.9 route");
assert.ok(/startOwnEmployerCheckout/.test(src), "the own-checkout trigger dispatches");
// The in-app mock-complete confirmation is honestly labeled as the
// pilot mock path (D-082 — the copy says mock, says no charge).
assert.ok(/completeOwnEmployerMockCheckout/.test(src), "the mock-complete arm dispatches");
assert.ok(/Pilot mock path/.test(src), "the mock path is labeled");
assert.ok(/no real charge/.test(src), "the copy says no real charge");
assert.ok(/Confirm purchase \(mock — no charge\)/.test(src), "the confirmation button is honest");
// P6-review fix pin: the confirm button is enabled at rest —
// `disabled={!confirming}` (the inverted guard) left the pilot buy
// flow dead: the confirm button was disabled UNTIL the request was
// already in flight. The honest shape disables ONLY while confirming.
const confirmBtn = src.slice(
src.indexOf('Confirm purchase (mock'),
src.indexOf('Cancel checkout'),
);
assert.ok(
/disabled=\{confirming\}/.test(confirmBtn),
"confirm disables only while confirming (never disabled at rest)",
);
assert.ok(
!/disabled=\{!confirming\}/.test(src),
"the inverted disabled={!confirming} guard is gone",
);
assert.ok(/mock payment provider/.test(src), "the provider is named honestly");
// The admin fallback is noted (D-094).
assert.ok(/operator can complete the purchase on your behalf|operator-assisted checkout remains available/.test(src), "the admin fallback is noted");
// The grant confirmation renders post-webhook (G-303 exactly-once
// server-side; the surface refetches the entitlement).
assert.ok(/Purchase complete/.test(src), "the grant confirmation renders");
assert.ok(/refetch/.test(src), "the entitlement refetches after completion");
// Honest 401/403 arms (never a retry loop).
assert.ok(/SessionRequiredError/.test(src), "the 401 arm is consumed");
assert.ok(/ClaimRequiredError/.test(src), "the claim-CTA arm is consumed");
assert.ok(/loggedOut/.test(src) && /claimPrompt/.test(src), "logged-out + claim-prompt states");
assert.ok(/AbortController/.test(src), "AbortController discipline");
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), "no retry loop machinery");
assert.ok(!/console\./.test(src), "no console logging");
// The 402 payload's byte-stable zeros are NEVER rendered as limits
// on this surface (G-320b) — real limits come from the entitlement.
const entitlementSection = src.slice(
src.indexOf("Your posting package"),
src.indexOf("Pilot payments run through"),
);
assert.ok(
/purchase\.posts_used/.test(entitlementSection) && /purchase\.posts_limit/.test(entitlementSection),
"limits render from the entitlement view only",
);
});
test("pin: the employer layout links the billing surface; the upgrade CTA's stale copy is gone", async () => {
const layout = readFileSync(web("app/(employer)/layout.tsx"), "utf8");
assert.ok(/\/employer\/billing/.test(layout), "the nav links the billing surface");
const page = readFileSync(web("app/(employer)/employer/billing/page.tsx"), "utf8");
assert.ok(/export const metadata/.test(page), "billing page metadata (A-411)");
assert.ok(/<EmployerBillingClient \/>/.test(page), "page renders the client (A-406)");
// The P4 upgrade notice's "arrives" copy is superseded — the surface
// shipped in P5.
const postings = readFileSync(
web("components/employer/own-postings-client.tsx"),
"utf8",
);
assert.ok(!/arrives on the employer/.test(postings), "the stale arrives copy is gone");
assert.ok(/Self-serve checkout lives on the billing surface/.test(postings), "the notice points at the live surface");
});
test("pin: ENTITLEMENT wire fixtures match the dual-schema mirror (store-emitted keys)", async () => {
// The store's exact emitted keys (billing/store.py get_entitlement)
// ride the TS mirror — asserted against the fixture shapes.
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const purchaseBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPurchaseEntitlement"),
typesSrc.indexOf("export interface EmployerSubscriptionEntitlement"),
);
for (const key of Object.keys(ENTITLEMENT_ACTIVE.purchase as Record<string, unknown>)) {
assert.ok(
purchaseBlock.includes(`${key}:`),
`the store key ${key} rides the TS mirror`,
);
}
assert.equal(ENTITLEMENT_EMPTY.purchase, null, "the honest empty state is null, never a fabricated row");
});
+140
View File
@@ -0,0 +1,140 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P3 (REQ-10-003, MH-3c/3-3d — Task 3-3-02): the dashboard
* conversion pins. The postings/overview pages are grep-clean of the
* removed mock imports; the talent pages' mock imports are RETAINED
* (D-092 honest out-of-scope); the session-gated clients carry the
* honest-state branches (logged-out/claim-CTA/claim-pending/loading/
* error) in source — the G-312 source-pin discipline (no render
* harness in-tree, none may be added).
*/
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
test("pin: postings + overview pages are grep-clean of the removed mock imports (MH-3c)", async () => {
const overview = readFileSync(web("app/(employer)/employer/page.tsx"), "utf8");
const postings = readFileSync(web("app/(employer)/employer/postings/page.tsx"), "utf8");
for (const [name, page] of [
["overview", overview],
["postings", postings],
] as const) {
assert.ok(!/@nextcraft\/mock-data/.test(page), `${name}: no mock-data import`);
assert.ok(!/\bjobs\b/.test(page), `${name}: no jobs mock`);
assert.ok(!/\bcandidates\b/.test(page), `${name}: no candidates mock`);
assert.ok(!/\bemployers\b\s*from/.test(page), `${name}: no employers mock`);
assert.ok(!/const METRICS/.test(page), `${name}: no inline METRICS constant`);
assert.ok(!/const PIPELINE/.test(page), `${name}: no inline PIPELINE constant`);
}
// The clients render the real own-postings surface, not the old
// mock-driven components (PostingManager's derived mock statuses/
// applicants + AnalyticsCharts' unlabeled mock charts are gone from
// the employer surface's real-data sections).
for (const client of [
"components/employer/employer-overview-client.tsx",
"components/employer/own-postings-client.tsx",
]) {
const src = readFileSync(web(client), "utf8");
assert.ok(!/@nextcraft\/mock-data/.test(src), `${client}: no mock-data import`);
assert.ok(!/deriveStatus|deriveApplicants/.test(src), `${client}: no derived mock statuses/applicants`);
}
assert.ok(
!/PostingManager/.test(postings),
"postings page no longer renders the mock-driven PostingManager",
);
assert.ok(
!/AnalyticsCharts/.test(overview),
"overview page no longer renders the unlabeled AnalyticsCharts",
);
});
test("pin: talent pages keep their mock imports + the mock-labeled metrics stay (MH-3d, D-092)", async () => {
const talent = readFileSync(web("app/(employer)/employer/talent/page.tsx"), "utf8");
const talentDetail = readFileSync(
web("app/(employer)/employer/talent/[candidateId]/page.tsx"),
"utf8",
);
// RETAINED (D-092: no candidate backend exists — honest out-of-scope).
assert.ok(/import \{ candidates \} from '@nextcraft\/mock-data'/.test(talent), "talent search keeps the candidates mock");
assert.ok(/import \{ candidates, competencyStacks \} from '@nextcraft\/mock-data'/.test(talentDetail), "candidate profile keeps the candidates mock");
// The overview's talent-match metrics stay MOCK-LABELED — the
// honest label rides the card copy (never presented as real).
// v0.11 P3: placement counts are now REAL (the D-092 conversion —
// the convertible surface left mock); only talent-match stays mock.
const overviewClient = readFileSync(
web("components/employer/employer-overview-client.tsx"),
"utf8",
);
assert.ok(/mock — no backend computes this/.test(overviewClient), "mock metric carries the honest label");
assert.ok(/MockMetricCard/.test(overviewClient), "the mock-labeled metric card exists");
assert.ok(/sample data/i.test(overviewClient), "analytics section labeled sample data");
assert.ok(/no backend\s+computes them yet/.test(overviewClient), "analytics notice is honest about the mock");
// The REAL arms: active-postings + applicant counts from the real
// route; the claimed-entity profile via the public marketplace fetch;
// v0.11: the placements section is REAL (listOwnEmployerPlacements).
assert.ok(/listOwnEmployerPostings/.test(overviewClient), "overview fetches own postings");
assert.ok(/applicant_count/.test(overviewClient), "overview renders per-posting applicant counts");
assert.ok(/getEmployer\(/.test(overviewClient), "overview fetches the claimed-entity profile (public route)");
assert.ok(/listOwnEmployerPlacements/.test(overviewClient), "overview fetches REAL placements (v0.11)");
assert.ok(/Placements Recorded/.test(overviewClient), "the real placement metric renders");
assert.ok(!/MockMetricCard label="Placement Rate"/.test(overviewClient), "the placement mock card is GONE (converted)");
});
test("pin: the session-gated clients carry every honest-state branch (MH-3b, G-312 source pins)", async () => {
for (const client of [
"components/employer/employer-overview-client.tsx",
"components/employer/own-postings-client.tsx",
]) {
const src = readFileSync(web(client), "utf8");
// SessionRequiredError → the logged-out state with a login CTA —
// NEVER a retry loop (the discrimination arm consumes the 401).
assert.ok(/SessionRequiredError/.test(src), `${client}: the 401 arm is consumed`);
assert.ok(/loggedOut/.test(src), `${client}: logged-out state exists`);
assert.ok(/href="\/employer\/login"/.test(src), `${client}: login CTA present`);
assert.ok(/Sign in/.test(src), `${client}: sign-in CTA copy`);
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), `${client}: no retry loop machinery`);
// ClaimRequiredError → the honest claim-CTA state (D-090 403 class).
assert.ok(/ClaimRequiredError/.test(src), `${client}: the claim-CTA arm is consumed`);
assert.ok(/claimPrompt/.test(src), `${client}: claim-prompt state exists`);
// Claim-pending state visible ("submitted, awaiting review" —
// never presented as verified before the operator decides).
assert.ok(/claimPending/.test(src), `${client}: claim-pending state exists`);
assert.ok(/awaiting review/.test(src), `${client}: awaiting-review copy`);
// Loading + error arms (A-406).
assert.ok(/AbortController/.test(src), `${client}: AbortController`);
assert.ok(/Loading/.test(src), `${client}: honest loading state`);
assert.ok(/error/.test(src), `${client}: honest error state`);
assert.ok(/controller\.signal\.aborted\) return;/.test(src), `${client}: aborted responses never render`);
}
});
test("pin: the own-postings lifecycle view renders the full lifecycle + real counts (MH-3c)", async () => {
const src = readFileSync(web("components/employer/own-postings-client.tsx"), "utf8");
// draft → published → closed visible (the route's full lifecycle —
// unlike the public board's published-only view).
for (const status of ["draft", "published", "closed"]) {
assert.ok(new RegExp(`'${status}'`).test(src), `lifecycle status ${status} renders`);
}
assert.ok(/facets\.draft/.test(src) && /facets\.published/.test(src) && /facets\.closed/.test(src), "facet counts render");
// Real applicant counts (the D-092 overview data source).
assert.ok(/applicant_count/.test(src), "applicant_count renders");
assert.ok(/No postings yet/.test(src), "honest empty state");
// P4 (Task 4-2-01) supersedes the read-only pin: the write actions
// are LIVE now — pinned in employer-postings-write.test.ts (the
// disabled/upcoming-release copy is gone, never fake-wired).
assert.ok(!/upcoming release/.test(src), "the P3 read-only copy is gone (P4 shipped the actions)");
assert.ok(!/disabled title=/.test(src), "no fake-wired disabled buttons remain");
// PII boundary (G-319): learner identifiers never render — only counts.
assert.ok(!/learner_id|applicant_name|candidate_id/.test(src), "counts only, no learner identifiers");
// The real route feeds the view.
assert.ok(/listOwnEmployerPostings/.test(src), "own-postings route feeds the view");
});
@@ -0,0 +1,307 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P4 (REQ-10-004, Task 4-2-01 — G-320b/G-312): the engine-client
* employer WRITE section + the PostingManager write actions. Fetch
* stubs pin the dispatch + refetch + the 402/403/409 discrimination;
* source pins cover the component's honest-state branches (no render
* harness in-tree — the D-059/E87 pin discipline).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrappers guard on
* window). location is included: engineBaseUrl reads
* window.location.hostname at module-import time (the auth-pages
* precedent). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
location: { hostname: "localhost" },
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const WRITE_RESPONSE = {
id: "emp-abc123",
source: "employer",
external_id: "emp-abc123",
employer_id: "emp-openai",
title: "AI Platform Engineer",
status: "draft",
};
test("createOwnEmployerPosting: POSTs the create route with the Bearer token + body on the wire", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string>; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(JSON.stringify(WRITE_RESPONSE), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-write");
const created = await mod.createOwnEmployerPosting({
title: "AI Platform Engineer",
description: "Build the platform.",
location: "San Francisco",
remote: true,
});
assert.equal(created.id, "emp-abc123");
assert.equal(created.source, "employer", "G-317: employer authorship rides the response");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/postings");
assert.equal(calls[0].method, "POST");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-write");
assert.ok(calls[0].body?.includes('"title":"AI Platform Engineer"'), "create body rides the wire");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("editOwnEmployerPosting: PATCHes the own-draft route; publish/close POST their sub-routes", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({ url: String(input), method: init?.method });
return new Response(JSON.stringify(WRITE_RESPONSE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await mod.editOwnEmployerPosting("job-1", { title: "Updated", description: "New copy." });
await mod.publishOwnEmployerPosting("job-1");
await mod.closeOwnEmployerPosting("job-1");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/postings/job-1");
assert.equal(calls[0].method, "PATCH", "edit is a PATCH");
assert.equal(
new URL(calls[1].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/publish",
);
assert.equal(calls[1].method, "POST", "publish is a POST");
assert.equal(
new URL(calls[2].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/close",
);
assert.equal(calls[2].method, "POST", "close is a POST");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("402 → UpgradeRequiredError with reason + upgrade_cta from the dict detail (G-320b client arm)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
// The D-091 wire contract: byte-stable zeros in BOTH arms —
// the client consumes reason + upgrade_cta ONLY (G-320b).
reason: "no_active_package",
posts_used: 0,
posts_limit: 0,
upgrade_cta:
"This employer has no active posting package — purchase one via /v1/employers/billing/checkout.",
},
}),
{ status: 402, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const { createOwnEmployerPosting, UpgradeRequiredError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
createOwnEmployerPosting({ title: "Blocked" }),
(err: unknown) => {
const upgrade = err as InstanceType<typeof UpgradeRequiredError>;
assert.ok(err instanceof UpgradeRequiredError, "must be UpgradeRequiredError");
assert.equal(upgrade.reason, "no_active_package");
assert.equal(
upgrade.upgradeCta,
"This employer has no active posting package — purchase one via /v1/employers/billing/checkout.",
);
assert.equal(upgrade.status, 402);
return true;
},
"402 maps to UpgradeRequiredError with reason + CTA",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("403 no_claimed_employer on a write route → ClaimRequiredError (claim-prompt on 403)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
reason: "no_claimed_employer",
claim_cta:
"This account has not claimed an employer entity yet — submit a claim via POST /v1/employers/claims.",
},
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const { createOwnEmployerPosting, ClaimRequiredError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
createOwnEmployerPosting({ title: "Blocked" }),
(err: unknown) => {
const claim = err as InstanceType<typeof ClaimRequiredError>;
assert.ok(err instanceof ClaimRequiredError, "must be ClaimRequiredError");
assert.equal(claim.reason, "no_claimed_employer");
return true;
},
"the claim-CTA 403 maps to ClaimRequiredError on write routes",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("publish/close dispatch: the wire verbs hit their sub-routes (the component refetches after — pinned in source)", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({ url: String(input), method: init?.method });
return new Response(JSON.stringify(WRITE_RESPONSE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await mod.publishOwnEmployerPosting("job-1");
await mod.closeOwnEmployerPosting("job-1");
assert.equal(
new URL(calls[0].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/publish",
);
assert.equal(calls[0].method, "POST", "publish dispatches a POST");
assert.equal(
new URL(calls[1].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/close",
);
assert.equal(calls[1].method, "POST", "close dispatches a POST");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("pin: PostingManager write actions — honest 402/403/409/404 branches + refetch (G-312 source pins)", async () => {
const src = readFileSync(web("components/employer/own-postings-client.tsx"), "utf8");
// The four write actions are wired to the engine-client functions.
for (const fn of [
"createOwnEmployerPosting",
"editOwnEmployerPosting",
"publishOwnEmployerPosting",
"closeOwnEmployerPosting",
]) {
assert.ok(src.includes(fn), `write action wired: ${fn}`);
}
// The four client functions take an AbortSignal (A-406).
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
for (const fn of [
"createOwnEmployerPosting",
"editOwnEmployerPosting",
"publishOwnEmployerPosting",
"closeOwnEmployerPosting",
]) {
assert.ok(
new RegExp(`export async function ${fn}\\([\\s\\S]*?signal\\?`).test(client),
`${fn} takes an AbortSignal`,
);
}
// 402 → the upgrade CTA with reason ONLY (G-320b binding).
assert.ok(/UpgradeRequiredError/.test(src), "the 402 arm is consumed");
assert.ok(/upgradeCta/.test(src), "the CTA text renders from the payload");
assert.ok(/reason === 'no_active_package'/.test(src), "reason drives the copy (no package)");
assert.ok(/reason === 'posting_limit_reached'/.test(src), "reason drives the copy (limit reached)");
assert.ok(/href="\/employer\/billing"/.test(src), "the CTA routes to the employer billing surface");
// The 402 branch NEVER renders the payload's zeroed usage fields as
// limits — real limits render exclusively from the entitlement view
// (P5's route; absent until then). Byte-stable source pin.
const upgradeBranch = src.slice(
src.indexOf("function UpgradeNotice"),
src.indexOf("function writeActionMessage"),
);
assert.ok(
!/posts_used|posts_limit/.test(upgradeBranch),
"G-320b: the 402 branch renders no usage/limit numbers",
);
// 403 claim-CTA → the claim prompt on write actions too.
assert.ok(/ClaimRequiredError/.test(src), "the claim-CTA arm is consumed");
assert.ok(/claimPrompt/.test(src), "claim-prompt state exists");
// 401 → honest logged-out state (never a retry loop).
assert.ok(/SessionRequiredError/.test(src), "the 401 arm is consumed");
assert.ok(/loggedOut/.test(src), "logged-out state exists");
// 409 own-drafts-only error copy (G-318).
assert.ok(/err\.status === 409/.test(src), "the 409 branch exists");
assert.ok(/Only your own drafts/.test(src), "honest own-drafts-only copy");
// 404 honest.
assert.ok(/err\.status === 404/.test(src), "the 404 branch exists");
assert.ok(/no longer exists/.test(src), "honest 404 copy");
// Per-action loading + error states.
assert.ok(/actionBusy/.test(src), "per-action loading state");
assert.ok(/actionError/.test(src), "per-action error state");
// NO optimistic updates — the refetch composes every action.
assert.ok(/refetch/.test(src), "the list refetches after actions");
assert.ok(/await refetch\(controller\.signal\)/.test(src), "refetch AWAITS the read route");
assert.ok(!/setPostings\(/.test(src.slice(src.indexOf("runAction"))), "no optimistic posting writes");
// AbortController + no retry machinery (A-406).
assert.ok(/AbortController/.test(src), "AbortController discipline");
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), "no retry loop machinery");
// No console logging (tokens/errors never logged).
assert.ok(!/console\./.test(src), "no console logging in the component");
});
test("pin: the dead mock PostingManager is gone (no mock-driven employer surface components)", async () => {
assert.ok(
!existsSync(web("components/employer/posting-manager.tsx")),
"the mock posting-manager.tsx is deleted (P4 write actions replace it)",
);
// The real surface is mock-free: no derived statuses, no mock imports.
const src = readFileSync(web("components/employer/own-postings-client.tsx"), "utf8");
assert.ok(!/@nextcraft\/mock-data/.test(src), "the real surface never imports mock data");
assert.ok(!/deriveStatus|deriveApplicants/.test(src), "no derived mock statuses/applicants");
});
+296
View File
@@ -0,0 +1,296 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P3 (REQ-10-003, D-092/R-61): the engine-client employer
* section — fetch stubs pin the 401 discrimination arm
* (SessionRequiredError), the Bearer transport on every employer
* fetch, and the logout revoke+clear contract. Source pins cover the
* no-retry-loop discipline (no render harness in-tree — the
* D-059/E87/G-312 pin discipline).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrappers guard on window). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const ME_BODY = {
account: {
id: "acct-1",
email: "ops@example.com",
employer_id: null,
created_at: "2026-09-30T00:00:00Z",
email_verified: false,
},
claimed_employer_id: null,
claim: null,
};
test("parseError: 401 maps to SessionRequiredError (the D-092 arm — no 401 discrimination existed before v0.10)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ detail: "invalid or expired session" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
try {
const { getEmployerMe, SessionRequiredError, EngineError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
getEmployerMe(),
(err: unknown) => {
assert.ok(err instanceof SessionRequiredError, "must be SessionRequiredError");
assert.ok(err instanceof EngineError, "rides the EngineError family");
assert.equal((err as InstanceType<typeof EngineError>).status, 401);
return true;
},
"401 maps to SessionRequiredError",
);
} finally {
globalThis.fetch = realFetch;
}
});
test("parseError: the unclaimed 403 claim-CTA maps to ClaimRequiredError (D-090 authorization class)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
reason: "no_claimed_employer",
claim_cta: "This account has not claimed an employer entity yet",
},
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
try {
const { listOwnEmployerPostings, ClaimRequiredError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
listOwnEmployerPostings(),
(err: unknown) => {
assert.ok(err instanceof ClaimRequiredError, "must be ClaimRequiredError");
assert.equal(
(err as InstanceType<typeof ClaimRequiredError>).reason,
"no_claimed_employer",
);
return true;
},
"claim-CTA 403 maps to ClaimRequiredError",
);
} finally {
globalThis.fetch = realFetch;
}
});
test("employer fetches: the Bearer token rides Authorization on every session-gated call", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify(ME_BODY), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-abc");
assert.equal(store.get("nc-employer-session"), "sess-token-abc", "wrapper writes the key");
await mod.getEmployerMe();
await mod.listOwnEmployerPostings();
await mod.claimEmployerEntity({ employer_id: "emp-openai" });
for (const [i, call] of calls.entries()) {
assert.equal(
call.headers["Authorization"],
"Bearer sess-token-abc",
`employer fetch #${i} carries the Bearer header`,
);
}
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/me");
assert.equal(new URL(calls[1].url, "http://localhost").pathname, "/v1/employers/postings");
assert.equal(new URL(calls[2].url, "http://localhost").pathname, "/v1/employers/claims");
assert.equal(calls[2].url.includes("/v1/employers/claims"), true, "claims POST path");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("logoutEmployer: clears the tab-scoped storage AND fires the revoke route", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify({ revoked: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-xyz");
await mod.logoutEmployer();
assert.equal(store.has("nc-employer-session"), false, "storage cleared on logout");
assert.equal(calls.length, 1, "the revoke route fired exactly once");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/logout");
assert.equal(calls[0].method, "POST", "logout is a POST");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-xyz", "revoke carries the dying token");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("logoutEmployer: no token → no revoke call (storage already clean, no zombie fetch)", async () => {
const realFetch = globalThis.fetch;
const calls: unknown[] = [];
globalThis.fetch = (async (...args: unknown[]) => {
calls.push(args);
return new Response("{}", { status: 200 });
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await mod.logoutEmployer();
assert.equal(calls.length, 0, "no revoke fetch without a stored token");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("logoutEmployer: an already-dead token (401) still leaves the caller logged out — no thrown error surface", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ detail: "invalid or expired session" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("already-dead");
await mod.logoutEmployer(); // must NOT reject
assert.equal(store.has("nc-employer-session"), false, "storage cleared even on the 401 path");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("registerEmployer/loginEmployer: POST the auth routes with the body on the wire", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(
JSON.stringify({ token: "tok-1", expires_at: "2026-10-07T00:00:00Z" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
const reg = await mod.registerEmployer({ email: "a@example.com", password: "longenough1" });
const log = await mod.loginEmployer({ email: "a@example.com", password: "longenough1" });
assert.equal(reg.token, "tok-1");
assert.equal(log.expires_at, "2026-10-07T00:00:00Z");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/register");
assert.equal(new URL(calls[1].url, "http://localhost").pathname, "/v1/employers/auth/login");
assert.equal(calls[0].method, "POST");
assert.ok(calls[0].body?.includes('"email":"a@example.com"'), "email rides the body");
// 422 honesty (R-56): the client never echoes the password into a
// log or storage — this test pins the body is JSON-only (the
// server's pii_fields scrub owns the 422 side).
assert.ok(calls[0].body?.startsWith("{"), "JSON body");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("pin: the employer section keeps the no-retry-loop + AbortController discipline (G-312 source pin)", async () => {
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
// SessionRequiredError exists + joins parseError before the generic fallthrough.
assert.ok(/export class SessionRequiredError/.test(client), "SessionRequiredError exported");
assert.ok(
/status === 401[\s\S]{0,200}SessionRequiredError/,
"the 401 arm maps to SessionRequiredError inside parseError",
);
// The 401 arm precedes the 403 arm (both are discrimination arms).
const idx401 = client.indexOf("resp.status === 401");
const idx403 = client.indexOf("resp.status === 403");
assert.ok(idx401 > 0 && idx403 > idx401, "401 discrimination precedes 403");
// The sessionStorage wrapper is window-guarded (the adminToken pattern).
assert.ok(
/typeof window === 'undefined'[\s\S]{0,120}sessionStorage.getItem\(EMPLOYER_SESSION_KEY\)/.test(
client,
),
"employerSessionToken is window-guarded",
);
// The employer functions own AbortController-compatible signal params.
for (const fn of [
"registerEmployer",
"loginEmployer",
"logoutEmployer",
"getEmployerMe",
"claimEmployerEntity",
"listOwnEmployerPostings",
]) {
assert.ok(
new RegExp(`export async function ${fn}\\([\\s\\S]*?signal\\?`).test(client),
`${fn} takes an AbortSignal`,
);
}
// No retry loop: the client never re-fetches on a 401 — the caller
// renders the logged-out state (pinned as the absence of retry logic).
assert.ok(
!/retry|setTimeout|while \(true\)/.test(
client.slice(client.indexOf("-- employer auth + account")),
),
"the employer section contains no retry machinery",
);
// The tab-scoped exposure tradeoff is documented in the section copy
// (open-risk-4: bound the XSS window, honest about it).
assert.ok(
/tab-scoped/.test(client),
"the sessionStorage tab-scoping rationale is documented",
);
});
@@ -0,0 +1,232 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.11 P5 (REQ-11-005, D-104/G-322): the learner placement-status
* client + panel wiring. Fetch stubs pin the wire shapes + the query
* composition (the billing-entitlement template); source pins cover
* the panel's mine-read-first composition, the honest state arms, and
* the A-304 badge honesty (the G-312 discipline — no render harness
* in-tree, none may be added).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
const STATUS_PAYLOAD = {
application: {
id: "app-1",
posting_id: "post-1",
learner_id: "pilot-learner",
status: "in_review",
created_at: "2026-10-21T00:00:00Z",
},
events: [
{
id: 1,
application_id: "app-1",
placement_id: null,
event_type: "application.in_review",
actor_type: "employer",
actor_ref: "employer:emp-test",
payload: {},
prev_hash: "0".repeat(64),
hash: "a".repeat(64),
created_at: "2026-10-21T00:01:00Z",
},
],
placement: null,
};
interface Call {
url: string;
method?: string;
body?: unknown;
headers: Record<string, string>;
}
function stubFetch(handler: () => Response): { calls: Call[]; restore: () => void } {
const realFetch = globalThis.fetch;
const calls: Call[] = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
headers: (init?.headers ?? {}) as Record<string, string>,
});
return handler();
}) as typeof fetch;
return { calls, restore: () => { globalThis.fetch = realFetch; } };
}
const ok = (payload: unknown) =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
test("getMyApplication: GETs the mine route with the (posting, learner) pair — G-322", async () => {
const { calls, restore } = stubFetch(() =>
ok({ application_id: "app-1", status: "submitted" }),
);
try {
const { getMyApplication } = await import(`../lib/engine-client.ts${QS}`);
const mine = await getMyApplication("post-1", "pilot-learner");
assert.equal(mine.application_id, "app-1");
assert.equal(mine.status, "submitted");
const url = new URL(calls[0].url, "http://localhost");
assert.equal(url.pathname, "/v1/marketplace/applications/mine");
assert.equal(url.searchParams.get("posting_id"), "post-1");
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
assert.equal(calls[0].method, undefined, "the discovery read is a GET");
} finally {
restore();
}
});
test("getMyApplication: no application → EngineError 404 (the apply-CTA arm)", async () => {
const { restore } = stubFetch(
() => new Response(JSON.stringify({ detail: "none" }), { status: 404 }),
);
try {
const { getMyApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(getMyApplication("post-1", "pilot-learner"), (err: unknown) => {
assert.ok(err instanceof EngineError, "must be EngineError");
const e = err as InstanceType<typeof EngineError>;
return e.status === 404;
});
} finally {
restore();
}
});
test("getLearnerApplicationStatus: GETs the status read with the learner query", async () => {
const { calls, restore } = stubFetch(() => ok(STATUS_PAYLOAD));
try {
const { getLearnerApplicationStatus } = await import(`../lib/engine-client.ts${QS}`);
const status = await getLearnerApplicationStatus("app-1", "pilot-learner");
assert.equal(status.application.status, "in_review");
assert.equal(status.events.length, 1);
assert.equal(status.events[0].event_type, "application.in_review");
assert.equal(status.placement, null);
const url = new URL(calls[0].url, "http://localhost");
assert.equal(url.pathname, "/v1/marketplace/applications/app-1");
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
} finally {
restore();
}
});
test("withdrawApplication: POSTs the withdraw body — the learner's only edit (D-104)", async () => {
const { calls, restore } = stubFetch(() =>
ok({ application_id: "app-1", posting_id: "post-1", status: "withdrawn" }),
);
try {
const { withdrawApplication } = await import(`../lib/engine-client.ts${QS}`);
const confirmation = await withdrawApplication("app-1", "pilot-learner");
assert.equal(confirmation.status, "withdrawn");
const url = new URL(calls[0].url, "http://localhost");
assert.equal(url.pathname, "/v1/marketplace/applications/app-1/withdraw");
assert.equal(calls[0].method, "POST");
assert.deepEqual(calls[0].body, { learner_id: "pilot-learner" });
} finally {
restore();
}
});
test("withdrawApplication: terminal → EngineError 409 (the honest matrix refusal)", async () => {
const { restore } = stubFetch(
() => new Response(JSON.stringify({ detail: "matrix" }), { status: 409 }),
);
try {
const { withdrawApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(withdrawApplication("app-1", "pilot-learner"), (err: unknown) => {
assert.ok(err instanceof EngineError, "must be EngineError");
const e = err as InstanceType<typeof EngineError>;
return e.status === 409;
});
} finally {
restore();
}
});
test("getLearnerApplicationStatus: foreign → EngineError 403 (the not-your-application arm)", async () => {
const { restore } = stubFetch(
() => new Response(JSON.stringify({ detail: "foreign" }), { status: 403 }),
);
try {
const { getLearnerApplicationStatus, EngineError } = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(getLearnerApplicationStatus("app-1", "pilot-learner"), (err: unknown) => {
assert.ok(err instanceof EngineError, "must be EngineError");
const e = err as InstanceType<typeof EngineError>;
return e.status === 403;
});
} finally {
restore();
}
});
test("pin: the panel composes mine-read first — never client-persisted application ids (G-322)", async () => {
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
assert.ok(/getMyApplication\(/.test(src), "the mine-read drives discovery");
assert.ok(/getLearnerApplicationStatus\(/.test(src), "the status read drives the panel");
assert.ok(/withdrawApplication\(/.test(src), "the withdraw CTA dispatches through the client");
assert.ok(
!/localStorage\.setItem|sessionStorage\.setItem/.test(src),
"no client-persisted application ids (no setItem anywhere)",
);
// mine 404 → the apply CTA (the honest absence arm)
assert.ok(
/mineErr instanceof EngineError && mineErr\.status === 404/.test(src),
"the 404 arm falls through to the apply CTA",
);
});
test("pin: the seven D-099 states render honestly (labels + withdraw gate + terminal/403 copy)", async () => {
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
for (const state of [
"'submitted'",
"'in_review'",
"'interview'",
"'offer'",
"'hired'",
"'rejected'",
"'withdrawn'",
]) {
assert.ok(src.includes(state), `the ${state} arm renders a label`);
}
assert.ok(
/WITHDRAWABLE_STATUSES/.test(src),
"the withdraw CTA gates on non-terminal sources (D-099)",
);
assert.ok(/status === 409/.test(src), "the 409 arm renders the honest terminal copy");
assert.ok(
/This application belongs to another learner\./.test(src),
"the foreign-403 copy renders",
);
});
test("pin: history rows + the placement-confirmation render from the status payload", async () => {
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
assert.ok(/appStatus\.events\.map\(/.test(src), "history rows render from events[]");
assert.ok(
/appStatus\.placement &&/.test(src),
"the placement confirmation renders when hired",
);
assert.ok(
/role_title/.test(src) && /start_date/.test(src),
"the placement payload fields render",
);
assert.ok(
/Placement recorded/.test(src),
"the placement copy says RECORDED (G-328 — never a verification claim)",
);
});
+679
View File
@@ -263,3 +263,682 @@ test("pricing page: runtime catalog wiring (REQ-9-002/D-079 — no hardcoded tie
"no raw cents arithmetic outside formatPrice",
);
});
// -- v0.10 employer accounts (REQ-10-003, G-108 dual-schema, MH-3a) ---------------
test("wire pin: EmployerAccount/EmployerClaim/token+me shapes mirror employers/base.py field-for-field (dual-schema sync)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/employers/base.py"),
"utf8",
);
// EmployerAccount: every pydantic field asserted on BOTH sides.
const acctBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerAccount"),
typesSrc.indexOf("export interface EmployerClaim {"),
);
for (const f of [
"id: string;",
"email: string;",
"employer_id: string | null;",
"created_at: string;",
"email_verified: false;",
]) {
assert.ok(acctBlock.includes(f), `EmployerAccount missing base.py field ${f}`);
}
// D-097: email_verified can never be true (Literal[False] both sides).
assert.ok(!/email_verified: boolean/.test(acctBlock), "email_verified is false, never boolean");
assert.ok(/email_verified: Literal\[False\]/.test(pySrc), "py side pins Literal[False]");
// EmployerClaim (+ the typed evidence dict).
const claimBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerClaim {"),
typesSrc.indexOf("export interface RegisterEmployerRequest {"),
);
for (const f of [
"id: string;",
"account_id: string;",
"employer_id: string;",
"status: EmployerClaimStatus;",
"evidence:",
"created_at: string;",
"decided_by: string | null;",
"decided_at: string | null;",
]) {
assert.ok(claimBlock.includes(f), `EmployerClaim missing base.py field ${f}`);
}
// The advisory evidence dict shape (claim_evidence.py compute_claim_evidence).
const evidenceBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerClaimEvidence"),
typesSrc.indexOf("export interface EmployerAccount"),
);
for (const f of [
"account_email_domain: string;",
"entity_website_domain: string;",
"match: boolean;",
]) {
assert.ok(evidenceBlock.includes(f), `EmployerClaimEvidence missing D-089 field ${f}`);
}
// Request shapes + TokenResponse + EmployerMe.
const reqBlock = typesSrc.slice(
typesSrc.indexOf("export interface RegisterEmployerRequest {"),
typesSrc.indexOf("export interface EmployerTokenResponse"),
);
for (const f of ["email: string;", "password: string;", "employer_id: string;"]) {
assert.ok(reqBlock.includes(f), `employer request shapes missing base.py field ${f}`);
}
const tokenBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerTokenResponse"),
typesSrc.indexOf("export interface EmployerMe"),
);
for (const f of ["token: string;", "expires_at: string;"]) {
assert.ok(tokenBlock.includes(f), `TokenResponse missing base.py field ${f}`);
}
const meBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerMe"),
typesSrc.indexOf("export type OwnPostingStatus"),
);
for (const f of [
"account: EmployerAccount;",
"claimed_employer_id: string | null;",
"claim: EmployerClaim | null;",
]) {
assert.ok(meBlock.includes(f), `EmployerMe missing base.py field ${f}`);
}
// The py side keeps the same fields in its pydantic contracts.
for (const f of [
"class EmployerAccount",
"class EmployerClaim",
"class RegisterEmployerRequest",
"class LoginEmployerRequest",
"class ClaimEmployerRequest",
"class TokenResponse",
"class EmployerMe",
"ClaimStatus",
"PASSWORD_MIN_LENGTH = 8",
]) {
assert.ok(pySrc.includes(f), `employers/base.py missing wire contract ${f}`);
}
// Claim status parity: the TS union mirrors the py Literal.
const tsStatuses = typesSrc.match(
/export type EmployerClaimStatus = '[a-z]+' \| '[a-z]+' \| '[a-z]+'/,
);
assert.ok(tsStatuses, "EmployerClaimStatus union exists");
assert.ok(
tsStatuses && tsStatuses[0].includes("'pending'") && tsStatuses[0].includes("'approved'") && tsStatuses[0].includes("'rejected'"),
"claim status union = pending|approved|rejected (py ClaimStatus)",
);
});
test("wire pin: OwnPostingSummary REUSES PostingSummary + own-postings response mirrors base.py (G-319/D-093)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const marketplaceSrc = readFileSync(web("../../packages/types/marketplace.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/employers/base.py"),
"utf8",
);
// G-319 REUSE: composition (extends PostingSummary), never a
// field-for-field duplicate of the PostingSummary fields.
const ownBlock = typesSrc.slice(
typesSrc.indexOf("export interface OwnPostingSummary"),
typesSrc.indexOf("export interface OwnPostingsFacets"),
);
assert.ok(
/export interface OwnPostingSummary extends PostingSummary \{/.test(ownBlock),
"OwnPostingSummary extends PostingSummary (reuse, never duplication)",
);
assert.ok(/applicant_count: number;/.test(ownBlock), "OwnPostingSummary carries applicant_count");
// The PostingSummary fields must NOT be re-listed inside the own block
// (a duplicate would drift — the REUSE contract).
for (const dup of ["external_id:", "required_competencies:", "salary_min:"]) {
assert.ok(!ownBlock.includes(dup), `OwnPostingSummary duplicates PostingSummary field ${dup}`);
}
// The py side subclasses marketplace.base.Posting (the same reuse).
assert.ok(/class OwnPostingSummary\(Posting\)/.test(pySrc), "py OwnPostingSummary subclasses Posting");
// PII boundary (G-319, binding): the contract carries the count only —
// no learner-identifier field name exists anywhere on the TS shape.
assert.ok(!/learner_id|applicant_name|candidate_id/.test(ownBlock), "PII boundary: counts only");
// OwnPostingsFacets: every lifecycle key on both sides.
const facetsBlock = typesSrc.slice(
typesSrc.indexOf("export interface OwnPostingsFacets"),
typesSrc.indexOf("export interface OwnPostingsResponse"),
);
for (const f of ["draft: number;", "published: number;", "closed: number;"]) {
assert.ok(facetsBlock.includes(f), `OwnPostingsFacets missing lifecycle key ${f}`);
}
assert.ok(/draft: int = 0/.test(pySrc) && /published: int = 0/.test(pySrc) && /closed: int = 0/.test(pySrc), "py OwnPostingsFacets carries the same three keys");
// OwnPostingsResponse: list + facets + honest total, both sides.
const respBlock = typesSrc.slice(typesSrc.indexOf("export interface OwnPostingsResponse"));
for (const f of [
"postings: OwnPostingSummary[];",
"facets: OwnPostingsFacets;",
"total: number;",
]) {
assert.ok(respBlock.includes(f), `OwnPostingsResponse missing base.py field ${f}`);
}
assert.ok(/postings: list\[OwnPostingSummary\]/.test(pySrc), "py OwnPostingsResponse carries the postings list");
// PostingSummary itself stays single-sourced in marketplace.ts (the
// wire pin at pins.test.ts:48 owns its field list).
assert.ok(
/export interface PostingSummary \{/.test(marketplaceSrc),
"PostingSummary remains defined once in marketplace.ts",
);
});
test("wire pin: employer types surface through the package index (the billing.ts precedent)", async () => {
const indexSrc = readFileSync(web("../../packages/types/index.ts"), "utf8");
assert.ok(
/export \* from '\.\/employer';/.test(indexSrc),
"index.ts re-exports employer.ts",
);
});
test("wire pin: EmployerPostingCreate/Edit/WriteResponse mirror employers/base.py field-for-field (P4 write contracts)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/employers/base.py"),
"utf8",
);
// EmployerPostingCreate: every pydantic field asserted on BOTH sides.
const createBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPostingCreate"),
typesSrc.indexOf("export interface EmployerPostingEdit"),
);
for (const f of [
"title: string;",
"description?: string;",
"required_competencies?: string[];",
"skills?: string[];",
"seniority?: string | null;",
"category?: string | null;",
"location?: string;",
"remote?: boolean;",
"salary_min?: number | null;",
"salary_max?: number | null;",
]) {
assert.ok(createBlock.includes(f), `EmployerPostingCreate missing base.py field ${f}`);
}
// The employer_id field is deliberately ABSENT (the entity derives
// from the session's claim, never the body — Task 4-1-02).
assert.ok(
!/employer_id/.test(createBlock),
"EmployerPostingCreate never carries employer_id (claim-derived)",
);
// The py side keeps the same fields (base.py EmployerPostingCreate).
const pyCreateBlock = pySrc.slice(
pySrc.indexOf("class EmployerPostingCreate"),
pySrc.indexOf("class EmployerPostingEdit"),
);
for (const f of [
"title: str = Field(min_length=1)",
"description: str =",
"required_competencies: list[str]",
"skills: list[str]",
"seniority: str | None",
"category: str | None",
"location: str =",
"remote: bool =",
"salary_min: int | None",
"salary_max: int | None",
]) {
assert.ok(pyCreateBlock.includes(f), `base.py EmployerPostingCreate missing ${f}`);
}
// EmployerPostingEdit: the partial shape (title/description/skills).
const editBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPostingEdit"),
typesSrc.indexOf("export interface EmployerPostingWriteResponse"),
);
for (const f of ["title?: string;", "description?: string;", "skills?: string[];"]) {
assert.ok(editBlock.includes(f), `EmployerPostingEdit missing base.py field ${f}`);
}
const pyEditBlock = pySrc.slice(
pySrc.indexOf("class EmployerPostingEdit"),
pySrc.indexOf("class EmployerPostingWriteResponse"),
);
for (const f of [
"title: str | None = Field(default=None, min_length=1)",
"description: str | None = None",
"skills: list[str] | None = None",
]) {
assert.ok(pyEditBlock.includes(f), `base.py EmployerPostingEdit missing ${f}`);
}
// EmployerPostingWriteResponse: the write-verdict shape both sides.
const respBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPostingWriteResponse"),
);
for (const f of [
"id: string;",
"source: string;",
"external_id: string;",
"employer_id: string;",
"title: string;",
"status: string;",
]) {
assert.ok(respBlock.includes(f), `EmployerPostingWriteResponse missing base.py field ${f}`);
}
const pyRespBlock = pySrc.slice(pySrc.indexOf("class EmployerPostingWriteResponse"));
for (const f of ["id: str", "source: str", "external_id: str", "employer_id: str", "title: str", "status: str"]) {
assert.ok(pyRespBlock.includes(f), `base.py EmployerPostingWriteResponse missing ${f}`);
}
});
// -- v0.10 P5 billing + pricing CTAs (REQ-10-005, Task 5-2-01) ----------------------
test("wire pin: EmployerCheckout/Entitlement/MockComplete shapes mirror employers/base.py field-for-field (P5 billing contracts)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/employers/base.py"),
"utf8",
);
// EmployerCheckoutCreate: item_id only (D-076's employer_id body
// field is GONE — the claimed entity is the only buyer).
const createBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerCheckoutCreate"),
typesSrc.indexOf("export interface EmployerCheckoutResponse"),
);
assert.ok(
createBlock.includes("item_id: string;"),
"EmployerCheckoutCreate carries item_id",
);
assert.ok(
!/employer_id/.test(createBlock),
"EmployerCheckoutCreate never carries employer_id (claim-derived buyer)",
);
// EmployerCheckoutResponse: the v0.9 CheckoutResponse shape reused.
const respBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerCheckoutResponse"),
typesSrc.indexOf("export interface EmployerPurchaseEntitlement"),
);
for (const f of [
"session_id: string;",
"provider_session_id: string;",
"url: string;",
"mode: string;",
]) {
assert.ok(respBlock.includes(f), `EmployerCheckoutResponse missing base.py field ${f}`);
}
// The purchase/subscription entitlement summaries — the EXACT keys
// billing/store.py get_entitlement emits (the v0.9 shape reused).
const purchaseBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPurchaseEntitlement"),
typesSrc.indexOf("export interface EmployerSubscriptionEntitlement"),
);
for (const f of [
"id: string;",
"package_id: string;",
"package_name: string;",
"posts_used: number;",
"posts_limit: number | null;",
"expires_at: string | null;",
]) {
assert.ok(purchaseBlock.includes(f), `EmployerPurchaseEntitlement missing store key ${f}`);
}
const subBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerSubscriptionEntitlement"),
typesSrc.indexOf("export interface EmployerEntitlementResponse"),
);
for (const f of [
"id: string;",
"plan_id: string;",
"plan_name: string;",
"status: string;",
"current_period_end: string | null;",
"past_due_at: string | null;",
]) {
assert.ok(subBlock.includes(f), `EmployerSubscriptionEntitlement missing store key ${f}`);
}
// EmployerEntitlementResponse: honest nulls until a real grant.
const entBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerEntitlementResponse"),
typesSrc.indexOf("export interface EmployerMockCompleteRequest"),
);
assert.ok(entBlock.includes("employer_id: string;"), "employer_id");
assert.ok(
entBlock.includes("purchase: EmployerPurchaseEntitlement | null;"),
"purchase is honestly nullable",
);
assert.ok(
entBlock.includes("subscription: EmployerSubscriptionEntitlement | null;"),
"subscription is honestly nullable",
);
// EmployerMockCompleteRequest/Response (D-082/G-301/G-302).
const reqBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerMockCompleteRequest"),
typesSrc.indexOf("export interface EmployerMockCompleteResponse"),
);
assert.ok(
reqBlock.includes("provider_session_id: string;"),
"the untrusted ID reference (G-302)",
);
const mockBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerMockCompleteResponse"),
);
for (const f of ["completed: boolean;", "payment_status: string;"]) {
assert.ok(mockBlock.includes(f), `EmployerMockCompleteResponse missing ${f}`);
}
// The py side keeps the same contracts (base.py P5 classes).
const pyP5Block = pySrc.slice(pySrc.indexOf("class EmployerCheckoutCreate"));
for (const f of [
"class EmployerCheckoutCreate",
"item_id: str = Field(min_length=1)",
"class EmployerCheckoutResponse",
"session_id: str",
"provider_session_id: str",
"url: str",
"mode: str",
"class EmployerEntitlementResponse",
"purchase: dict[str, Any] | None = None",
"subscription: dict[str, Any] | None = None",
"class EmployerMockCompleteRequest",
"class EmployerMockCompleteResponse",
"completed: bool",
"payment_status: str",
]) {
assert.ok(pyP5Block.includes(f), `base.py P5 billing contract missing ${f}`);
}
});
test("wire pin: admin overview shapes mirror employers_admin.py operator contracts (Task 5-2-02, D-094)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/api/employers_admin.py"),
"utf8",
);
// AdminEmployerAccount: the accounts-list wire row, both sides.
const acctBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminEmployerAccount"),
typesSrc.indexOf("export interface AdminAccountsResponse"),
);
for (const f of [
"id: string;",
"email: string;",
"employer_id: string | null;",
"created_at: string;",
"email_verified: false;",
]) {
assert.ok(acctBlock.includes(f), `AdminEmployerAccount missing employers_admin.py field ${f}`);
}
// D-097: the unverified marker can never be true; D-087: the
// password hash is NEVER a wire field.
assert.ok(!/email_verified: boolean/.test(acctBlock), "email_verified is false, never boolean");
assert.ok(!/password_hash/.test(acctBlock), "password_hash never rides the wire (D-087)");
assert.ok(/email_verified: Literal\[False\]/.test(pySrc), "py side pins Literal[False]");
// AdminAccountsResponse / AdminClaimsResponse / ClaimDecisionResponse.
const accountsRespBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminAccountsResponse"),
typesSrc.indexOf("export interface AdminClaimsResponse"),
);
assert.ok(
accountsRespBlock.includes("accounts: AdminEmployerAccount[];"),
"AdminAccountsResponse.accounts",
);
assert.ok(accountsRespBlock.includes("count: number;"), "AdminAccountsResponse.count");
const claimsRespBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminClaimsResponse"),
typesSrc.indexOf("export interface ClaimDecisionResponse"),
);
assert.ok(
claimsRespBlock.includes("claims: EmployerClaim[];"),
"the queue reuses the EmployerClaim wire shape",
);
assert.ok(claimsRespBlock.includes("count: number;"), "AdminClaimsResponse.count");
const decisionBlock = typesSrc.slice(
typesSrc.indexOf("export interface ClaimDecisionResponse"),
typesSrc.indexOf("export interface AdminAuditEvent"),
);
assert.ok(decisionBlock.includes("claim: EmployerClaim;"), "ClaimDecisionResponse.claim");
// AdminAuditEvent / AdminAuditResponse — the BillingEventRow shape.
const auditBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminAuditEvent"),
typesSrc.indexOf("export interface AdminAuditResponse"),
);
for (const f of [
"id: number;",
"account_id: string | null;",
"employer_id: string | null;",
"kind: string;",
"name: string;",
"detail: Record<string, unknown>;",
"operator: string | null;",
"created_at: string | null;",
]) {
assert.ok(auditBlock.includes(f), `AdminAuditEvent missing employers_admin.py field ${f}`);
}
const auditRespBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminAuditResponse"),
);
assert.ok(auditRespBlock.includes("events: AdminAuditEvent[];"), "AdminAuditResponse.events");
assert.ok(auditRespBlock.includes("count: number;"), "AdminAuditResponse.count");
// The py side keeps the same contracts (employers_admin.py).
for (const f of [
"class AdminEmployerAccount",
"class AdminAccountsResponse",
"class AdminClaimsResponse",
"class ClaimDecisionResponse",
"class AdminAuditEvent",
"class AdminAuditResponse",
"claims: list[EmployerClaim]",
"claim: EmployerClaim",
"events: list[AdminAuditEvent]",
]) {
assert.ok(pySrc.includes(f), `employers_admin.py missing wire contract ${f}`);
}
});
test("pricing pin: purchasable CTAs route to /employer/login; the enterprise Contact Sales pin STAYS (Task 5-2-01, MH-5d)", async () => {
const client = readFileSync(
web("components/marketplace/pricing-client.tsx"),
"utf8",
);
// The employer-login path is the purchasable-tier CTA href.
assert.ok(
/href=\{isEnterprise \? '[^']*' : '\/employer\/login'\}/.test(client),
"purchasable tiers route to the employer-login path",
);
// The enterprise contact CTA is RETAINED (R-61: the pins.test.ts:250
// pin holds — price_cents=0 rows stay operator-mediated).
assert.ok(/Contact Sales/.test(client), "enterprise Contact Sales retained");
assert.ok(
/if \(isEnterprise\) return 'Contact Sales';/.test(client),
"enterprise arm keeps the contact copy",
);
// The operator-mediated-only pilot copy is GONE — replaced by the
// self-serve truth with the admin fallback noted.
assert.ok(
!/operator-mediated during the pilot/.test(client),
"the D-079 operator-mediated copy is superseded",
);
assert.ok(
/operator-assisted checkout remains available as a fallback/.test(client),
"the admin-fallback is noted honestly",
);
assert.ok(/mock provider \(honestly labeled/.test(client), "the pilot mock path is labeled");
});
// v0.11 P3 (D-110): the placement wire pins — placement.ts mirrors
// placements/base.py field-for-field (the G-108 discipline; the
// employer.ts block's template). Every change happens in both places.
test("wire pin: Placement mirrors placements/base.py PlacementRead field-for-field (dual-schema sync)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const pySrc = readFileSync(
web("../../apps/ai-service/ai_service/placements/base.py"),
"utf8",
);
const block = typesSrc.slice(
typesSrc.indexOf("export interface Placement {"),
typesSrc.indexOf("export interface PlacementEvent"),
);
const fields = [
"id:", "learner_id:", "employer_id:", "posting_id:",
"application_id:", "role_title:", "start_date:", "status:",
"created_at:", "decided_at:",
];
for (const f of fields) {
assert.ok(block.includes(f), `Placement missing base.py field ${f}`);
}
// start_date is a DATE on both sides (D-103) — never a datetime
assert.ok(
/start_date: date/.test(pySrc),
"base.py start_date is a pydantic date",
);
assert.ok(
/start_date: string;/.test(block),
"placement.ts start_date is the ISO date string",
);
});
test("wire pin: ApplicationStatus union mirrors the D-099 matrix (status-union regex pin)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const block = typesSrc.slice(
typesSrc.indexOf("export type ApplicationStatus"),
typesSrc.indexOf("export type PlacementStatus"),
);
// the seven D-099 statuses, in matrix order
const statuses = [
"'submitted'", "'in_review'", "'interview'", "'offer'",
"'hired'", "'rejected'", "'withdrawn'",
];
for (const status of statuses) {
assert.ok(block.includes(status), `ApplicationStatus missing ${status}`);
}
assert.ok(
!/"submitted"/.test(block),
"snake_case on the wire (no kebab 'in-review')",
);
});
test("wire pin: PlacementOverview mirrors the D-107 computation shape", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const block = typesSrc.slice(
typesSrc.indexOf("export interface PlacementOverview"),
);
const fields = [
"total_active_placements:", "distinct_learners_placed:",
"guiding_goal:", "by_employer:", "by_cohort_month:",
];
for (const f of fields) {
assert.ok(block.includes(f), `PlacementOverview missing ${f}`);
}
assert.ok(
/export const GUIDING_GOAL = 1000;/.test(typesSrc),
"the north star is 1,000 learners",
);
});
test("wire pin: EmployerApplicationSummary carries learner_id per the G-319 supersession (D-105)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const block = typesSrc.slice(
typesSrc.indexOf("export interface EmployerApplicationSummary"),
typesSrc.indexOf("export interface EmployerApplicationsResponse"),
);
assert.ok(/learner_id: string;/.test(block), "learner_id rides the summary");
// the own-postings LIST stays counts-only: OwnPostingSummary (the
// v0.10 contract) still carries ONLY applicant_count — never a
// learner_id field
const employerTypes = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const ownBlock = employerTypes.slice(
employerTypes.indexOf("export interface OwnPostingSummary"),
employerTypes.indexOf("export interface OwnPostingsFacets"),
);
assert.ok(
!/learner_id/.test(ownBlock),
"the G-319 pin split: OwnPostingSummary stays counts-only",
);
});
// v0.11 P5 (REQ-11-005, D-110): the learner status-read wire pins +
// the A-304 badge-honesty census — no placement badge on the mock
// talent surfaces (D-092 binding: a badge on a mock page is
// fabricated placement); the learner's-own application surface is the
// ONLY placement render.
test("wire pin: LearnerApplicationStatus mirrors the base.py learner status read (D-104)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const pySrc = readFileSync(
web("../../apps/ai-service/ai_service/placements/base.py"),
"utf8",
);
const summaryBlock = typesSrc.slice(
typesSrc.indexOf("export interface LearnerApplicationSummary"),
typesSrc.indexOf("export interface LearnerApplicationStatus"),
);
for (const f of ["id:", "posting_id:", "learner_id:", "status:", "created_at:"]) {
assert.ok(summaryBlock.includes(f), `LearnerApplicationSummary missing ${f}`);
}
const statusBlock = typesSrc.slice(
typesSrc.indexOf("export interface LearnerApplicationStatus"),
);
assert.ok(/application: LearnerApplicationSummary;/.test(statusBlock));
assert.ok(/events: PlacementEvent\[\];/.test(statusBlock));
assert.ok(/placement: Placement \| null;/.test(statusBlock));
// the backend contract carries the same three arms (base.py)
const pyBlock = pySrc.slice(pySrc.indexOf("class LearnerApplicationStatusResponse"));
assert.ok(/application: LearnerApplicationSummary/.test(pyBlock));
assert.ok(/events: list\[PlacementEventRead\]/.test(pyBlock));
assert.ok(/placement: PlacementRead \| None/.test(pyBlock));
// the mine-read contract (G-322)
const mineBlock = typesSrc.slice(typesSrc.indexOf("export interface MineApplication"));
assert.ok(/application_id: string;/.test(mineBlock));
assert.ok(/status: ApplicationStatus;/.test(mineBlock));
assert.ok(
/class MineApplicationResponse/.test(pySrc),
"base.py carries the mine contract",
);
});
test("pin: A-304 badge honesty — no placement badge on the mock talent surfaces (D-092/D-109)", async () => {
const surfaces = [
"app/(employer)/employer/talent/page.tsx",
"app/(employer)/employer/talent/[candidateId]/page.tsx",
"components/employer/talent-search.tsx",
];
for (const surface of surfaces) {
const src = readFileSync(web(surface), "utf8");
assert.ok(
!/placement/i.test(src),
`${surface}: a badge on a mock surface is fabricated placement (D-092 binding)`,
);
}
// the learner's-own application surface is the ONLY placement render
const jobDetail = readFileSync(
web("components/marketplace/job-detail-client.tsx"),
"utf8",
);
assert.ok(
/appStatus\.placement &&/.test(jobDetail),
"the placement confirmation renders only on the learner's-own application surface",
);
const placementBlock = jobDetail.slice(
jobDetail.indexOf("appStatus.placement &&"),
jobDetail.indexOf("{WITHDRAWABLE_STATUSES.has("),
);
assert.ok(
/Placement recorded/.test(placementBlock),
"G-328: the placement copy says RECORDED",
);
assert.ok(
!/verified/i.test(placementBlock),
"G-328: no verification claim on the placement surface",
);
});
+2
View File
@@ -1,5 +1,7 @@
# Deploying Nextcraft — the admin runbook (v0.8)
**TL;DR** — AI-native outcome school + marketplace — graduates prove what they can build, not what they can write. This file explains how to stand the stack up on any Debian-based server: pick one of three install channels, run `nextcraft deploy --service`, and verify with the checklist; troubleshooting tables and evidence sit lower in the file.
Production topology since v0.3.6: **one port, one process**. The ai-service
(uvicorn, `:8420`) serves **both** the API and the exported web UI, so the
site works behind any proxy that can reach exactly one backend port — e.g.
+320
View File
@@ -0,0 +1,320 @@
/**
* Nextcraft — Employer Account Types
*
* Employer auth/account wire shapes (v0.10 P3, REQ-10-003): the
* employer-principal surface served by /v1/employers/*.
*
* Dual-schema-sync mirror of
* `apps/ai-service/ai_service/employers/base.py` (the G-108
* discipline): field-for-field parity, snake_case as emitted by the
* API — every change happens in both places, pinned by
* `apps/web/tests/pins.test.ts`.
*
* D-097: `email_verified` is `false` — the account is UNVERIFIED,
* displayed as such; never presented as verified.
*
* D-093/G-319: `OwnPostingSummary` REUSES the PostingSummary wire
* shape from marketplace.ts (composition, never a field-for-field
* duplication) + `applicant_count`. PII boundary (binding): applicant
* COUNTS are the only applications-derived datum — learner
* identifiers never ride any /v1/employers/* response.
*/
import type { PostingSummary, PostingStatus } from './marketplace';
/** Claim lifecycle states (D-089): pending until an operator decides.
* Approved activates ownership (one account : one entity); rejection
* frees the entity for a new claim. */
export type EmployerClaimStatus = 'pending' | 'approved' | 'rejected';
/** Advisory claim evidence (D-089, computed AT CLAIM TIME by
* claim_evidence.py — a snapshot stored as provenance JSON, never
* recomputed on read). The domain-match signal is ADVISORY and never
* displayed as verification (A-304). */
export interface EmployerClaimEvidence {
account_email_domain: string;
entity_website_domain: string;
match: boolean;
}
/** An employer-side login account (base.py EmployerAccount). */
export interface EmployerAccount {
id: string;
/** G-315: plain string — stdlib-validated + normalized server-side. */
email: string;
/** None until a claim is APPROVED (one account : one entity). */
employer_id: string | null;
created_at: string;
/** D-097: UNVERIFIED — always false, displayed as such. */
email_verified: false;
}
/** An account's claim on an EmployerRow entity (base.py EmployerClaim). */
export interface EmployerClaim {
id: string;
account_id: string;
employer_id: string;
status: EmployerClaimStatus;
/** The claim-time evidence snapshot (advisory, never verification). */
evidence: Partial<EmployerClaimEvidence> & Record<string, unknown>;
created_at: string;
/** Operator provenance — null while pending. */
decided_by: string | null;
decided_at: string | null;
}
/** POST /v1/employers/auth/register body (base.py RegisterEmployerRequest:
* G-315 email + D-086 password min length 8, validated server-side). */
export interface RegisterEmployerRequest {
email: string;
password: string;
}
/** POST /v1/employers/auth/login body (base.py LoginEmployerRequest). */
export interface LoginEmployerRequest {
email: string;
password: string;
}
/** POST /v1/employers/claims body (base.py ClaimEmployerRequest). */
export interface ClaimEmployerRequest {
employer_id: string;
}
/** Login/register session response (base.py TokenResponse): the bearer
* token (raw, exactly once — only sha256(token) is stored server-side,
* D-087) + its lazy-expiry timestamp. */
export interface EmployerTokenResponse {
token: string;
expires_at: string;
}
/** GET /v1/employers/auth/me response (base.py EmployerMe): the
* account (unverified email displayed as such) + the claimed entity +
* the claim state — null until a claim exists. */
export interface EmployerMe {
account: EmployerAccount;
claimed_employer_id: string | null;
claim: EmployerClaim | null;
}
/** G-319: the employer-facing posting summary — the PostingSummary
* wire shape REUSED from marketplace.ts (composition, never
* duplicated) + the per-posting applicant COUNT (the D-092 overview
* data source). PII boundary: counts ONLY, never learner identifiers. */
export interface OwnPostingSummary extends PostingSummary {
applicant_count: number;
}
/** Status facet counts over the session employer's OWN postings
* (base.py OwnPostingsFacets): the full lifecycle — draft|published|
* closed — every key always present (a zero-count status carries its
* honest 0). */
export interface OwnPostingsFacets {
draft: number;
published: number;
closed: number;
}
/** GET /v1/employers/postings response (base.py OwnPostingsResponse):
* own postings (all lifecycle statuses) + per-status facet counts +
* the honest total. Unclaimed accounts never see this shape (the
* route's 403 claim-CTA precedes it). */
export interface OwnPostingsResponse {
postings: OwnPostingSummary[];
facets: OwnPostingsFacets;
total: number;
}
/** The own-postings status facet keys (base.py OWN_POSTING_STATUSES —
* the marketplace PostingStatus lifecycle, all of it, unlike the
* public board's published-only view). */
export type OwnPostingStatus = PostingStatus;
/** POST /v1/employers/postings body (base.py EmployerPostingCreate,
* Task 4-1-02): the employer-authored draft — the AdminPostingCreate
* shape MINUS employer_id (the entity derives from the session's
* CLAIM, never the body). Starts as a DRAFT; publish is explicit (the
* admin-route lifecycle discipline). Field-for-field mirror: title is
* required (min_length 1); every other field carries the pydantic
* default and may be omitted on the wire. */
export interface EmployerPostingCreate {
title: string;
description?: string;
required_competencies?: string[];
skills?: string[];
seniority?: string | null;
category?: string | null;
location?: string;
remote?: boolean;
salary_min?: number | null;
salary_max?: number | null;
}
/** PATCH /v1/employers/postings/{id} body (base.py EmployerPostingEdit,
* G-318): partial content edit — fields omitted stay put; a
* successful edit RE-TAGS the posting (tags are derived data, D-052).
* Own DRAFTS only (published/closed → 409). */
export interface EmployerPostingEdit {
title?: string;
description?: string;
skills?: string[];
}
/** Create/edit/publish/close response (base.py
* EmployerPostingWriteResponse): the write-verdict shape — G-317's
* source stamp rides it ("employer" on every employer-authored row;
* seeded/admin rows keep their own). */
export interface EmployerPostingWriteResponse {
id: string;
source: string;
external_id: string;
employer_id: string;
title: string;
status: string;
}
// P5 (REQ-10-005): the self-serve checkout contracts — mirrors of
// base.py's EmployerCheckoutCreate/Response, EmployerEntitlementResponse
// and EmployerMockCompleteRequest/Response. The checkout/entitlement
// contracts REUSE the v0.9 billing wire shapes where they coincide
// (D-093) — the py side carries the purchase/subscription summaries as
// honest dicts; the TS mirror types the exact keys the store emits.
/** POST /v1/employers/billing/checkout body (base.py
* EmployerCheckoutCreate, 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). */
export interface EmployerCheckoutCreate {
item_id: string;
}
/** POST /v1/employers/billing/checkout response (base.py
* EmployerCheckoutResponse): the created session handle + the local
* reconciliation id — the v0.9 billing_admin.CheckoutResponse shape
* REUSED verbatim (one wire contract for both checkout principals). */
export interface EmployerCheckoutResponse {
session_id: string;
provider_session_id: string;
url: string;
mode: string;
}
/** The active-purchase entitlement summary riding
* EmployerEntitlementResponse.purchase (the v0.9 shape — the exact
* keys billing/store.py get_entitlement emits). `posts_limit` null =
* unlimited; `expires_at` null = no expiry. Expired windows are
* skipped lazily at read (G-306) — an expired purchase never rides
* this shape. */
export interface EmployerPurchaseEntitlement {
id: string;
package_id: string;
package_name: string;
posts_used: number;
posts_limit: number | null;
expires_at: string | null;
}
/** The subscription entitlement summary riding
* EmployerEntitlementResponse.subscription (the v0.9 shape — the exact
* keys the store emits; lazy past_due → expired grace per G-306). */
export interface EmployerSubscriptionEntitlement {
id: string;
plan_id: string;
plan_name: string;
status: string;
current_period_end: string | null;
past_due_at: string | null;
}
/** GET /v1/employers/billing/entitlement response (base.py
* EmployerEntitlementResponse): the session employer's OWN entitlement
* summary resolved for the CLAIMED entity only. Honest states:
* purchase/subscription are null until a real grant exists (the
* surface renders the honest empty state from this). The wire shape
* CANNOT distinguish never-purchased from expired (the store skips
* expired windows lazily, G-306 — both read as null) — surfaces must
* never assert expiry provenance from a null purchase. */
export interface EmployerEntitlementResponse {
employer_id: string;
purchase: EmployerPurchaseEntitlement | null;
subscription: EmployerSubscriptionEntitlement | null;
}
/** POST /v1/employers/billing/mock/complete body (base.py
* EmployerMockCompleteRequest, 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. */
export interface EmployerMockCompleteRequest {
provider_session_id: string;
}
/** POST /v1/employers/billing/mock/complete response (base.py
* EmployerMockCompleteResponse): the completion result — the v0.9
* MockCompleteResponse shape REUSED verbatim. */
export interface EmployerMockCompleteResponse {
completed: boolean;
payment_status: string;
}
// P5 (REQ-10-005, D-094): the admin network-overview wire shapes —
// mirrors of api/employers_admin.py's operator contracts (the G-108
// discipline, both sides pinned by pins.test.ts).
/** The accounts-list wire row (employers_admin.py
* AdminEmployerAccount): email, claimed entity, created_at, the
* UNVERIFIED marker. `email_verified` is `false` — structurally
* incapable of true (D-097); password_hash NEVER rides the wire
* (D-087). */
export interface AdminEmployerAccount {
id: string;
email: string;
employer_id: string | null;
created_at: string;
/** D-097: the account is UNVERIFIED — displayed as such. */
email_verified: false;
}
/** GET /v1/employers/admin/accounts response
* (AdminAccountsResponse). */
export interface AdminAccountsResponse {
accounts: AdminEmployerAccount[];
count: number;
}
/** GET /v1/employers/admin/claims response (AdminClaimsResponse): the
* claim queue — status, the stored evidence snapshot (ADVISORY,
* never verification — A-304), the decided state. */
export interface AdminClaimsResponse {
claims: EmployerClaim[];
count: number;
}
/** POST /v1/employers/admin/claims/{id}/approve|reject response
* (ClaimDecisionResponse): the decided claim (status + operator
* provenance) — either decision arm returns the same shape. */
export interface ClaimDecisionResponse {
claim: EmployerClaim;
}
/** The audit-trail wire row (employers_admin.py AdminAuditEvent —
* the BillingEventRow shape): operator provenance on every claim
* decision. */
export interface AdminAuditEvent {
id: number;
account_id: string | null;
employer_id: string | null;
kind: string;
name: string;
detail: Record<string, unknown>;
operator: string | null;
created_at: string | null;
}
/** GET /v1/employers/admin/audit response (AdminAuditResponse). */
export interface AdminAuditResponse {
events: AdminAuditEvent[];
count: number;
}
+2
View File
@@ -1,8 +1,10 @@
export * from './domain';
export * from './billing';
export * from './employer';
export * from './defense';
export * from './grading';
export * from './marketplace';
export * from './placement';
export * from './telemetry';
export * from './user';
export * from './ui';
+167
View File
@@ -0,0 +1,167 @@
/**
* Nextcraft — Placement Types
*
* Placement-tracking wire shapes (v0.11, REQ-11-001..005): the
* hiring-pipeline surface served by /v1/employers/applications/*,
* /v1/marketplace/applications/*, and /v1/placements/admin/*.
*
* Dual-schema-sync mirror of
* `apps/ai-service/ai_service/placements/base.py` (the G-108
* discipline, D-110): field-for-field parity, snake_case as emitted
* by the API — every change happens in both places, pinned by
* `apps/web/tests/pins.test.ts`.
*
* D-099: the application status set is forward-only with
* skip-forward; hired/rejected/withdrawn are TERMINAL.
*
* G-319 supersession (D-105): learner_id IS visible on
* application-scoped reads for the session's own claimed-entity
* postings — the own-postings LIST stays counts-only.
*/
/** D-099: the hiring lifecycle (snake_case, the billing `past_due`
* precedent). hired/rejected/withdrawn are terminal — no outgoing
* edges. */
export type ApplicationStatus =
| 'submitted'
| 'in_review'
| 'interview'
| 'offer'
| 'hired'
| 'rejected'
| 'withdrawn';
/** D-102: the placement lifecycle — created active; the ONLY
* transition is the operator correction active → revoked. */
export type PlacementStatus = 'active' | 'revoked';
/** A placement record (base.py PlacementRead). employer/learner/
* posting are ID REFERENCES resolved from source rows at read/export
* (G-302) — never trusted denormalized copies. start_date is the
* employer-attested employment start DATE (D-103), never a
* datetime. */
export interface Placement {
id: string;
learner_id: string;
employer_id: string;
posting_id: string;
application_id: string;
role_title: string;
start_date: string;
status: PlacementStatus;
created_at: string;
decided_at: string;
}
/** One ledger event (base.py PlacementEventRead): the append-only
* hash-chained trail — application-stage events carry
* application_id (placement_id null); the placement lane carries
* placement_id set. */
export interface PlacementEvent {
id: number;
application_id: string | null;
placement_id: string | null;
event_type: string;
actor_type: string;
actor_ref: string;
payload: Record<string, unknown>;
prev_hash: string;
hash: string;
created_at: string;
}
/** An employer-facing application row (base.py
* EmployerApplicationSummary, D-105/G-319): learner_id visible on
* application-scoped reads ONLY (the own-postings list stays
* counts-only — the pin split). */
export interface EmployerApplicationSummary {
id: string;
posting_id: string;
learner_id: string;
status: ApplicationStatus;
created_at: string | null;
}
/** GET /v1/employers/applications response (base.py
* EmployerApplicationsResponse): own-pipeline applications (every
* status) + per-status facet counts (every status key ALWAYS
* present — a zero-count status carries its honest 0). */
export interface EmployerApplicationsResponse {
applications: EmployerApplicationSummary[];
facets: Record<ApplicationStatus, number>;
total: number;
}
/** The transition result (base.py ApplicationTransitionResponse):
* the application's new status + the placement record when the
* transition created one (hire). */
export interface ApplicationTransitionResponse {
application_id: string;
status: ApplicationStatus;
placement: Placement | null;
}
/** POST .../hire body (base.py HireTransitionRequest, D-103/G-324):
* role_title is employer-attested free text (1..200 — the house
* bounds); start_date is the employment start DATE (YYYY-MM-DD). */
export interface HireTransitionRequest {
role_title: string;
start_date: string;
}
/** GET /v1/employers/placements response (base.py
* EmployerPlacementsResponse): the claimed entity's OWN placements
* (active + revoked — the full history), created_at then id. */
export interface EmployerPlacementsResponse {
placements: Placement[];
total: number;
}
/** D-107: the guiding outcome's north star (PROJECT.md line 7 — the
* noun is LEARNERS, not placements). */
export const GUIDING_GOAL = 1000;
/** GET /v1/placements/admin/overview response (base.py
* PlacementOverview, D-107): counts computed from rows at query
* time — never denormalized counters. 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). */
export interface PlacementOverview {
total_active_placements: number;
distinct_learners_placed: number;
guiding_goal: number;
by_employer: Array<{ employer_id: string; count: number }>;
by_cohort_month: Array<{ month: string; count: number }>;
}
/** The application row on the learner status read (base.py
* LearnerApplicationSummary, D-104): the mine-visible fields only —
* ids + status + the apply timestamp (nullable per the v0.6 row). */
export interface LearnerApplicationSummary {
id: string;
posting_id: string;
learner_id: string;
status: ApplicationStatus;
created_at: string | null;
}
/** GET /v1/marketplace/applications/{application_id}?learner_id=
* response (base.py LearnerApplicationStatusResponse, REQ-11-005):
* the learner's-own application read — current status + the full
* id-ordered event history for THIS application + the placement
* record when hired (the honest null otherwise, A-304). */
export interface LearnerApplicationStatus {
application: LearnerApplicationSummary;
events: PlacementEvent[];
placement: Placement | null;
}
/** GET /v1/marketplace/applications/mine response (base.py
* MineApplicationResponse, G-322): the panel's application discovery
* on revisit — a 404 renders the apply CTA (the panel never depends
* on client-persisted application_ids). */
export interface MineApplication {
application_id: string;
status: ApplicationStatus;
}