Compare commits

...

44 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
33 changed files with 4525 additions and 49 deletions
-8
View File
@@ -1,8 +0,0 @@
{
"phase": 1,
"stage": "verify",
"milestone": "v0.11",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-10-01T05:20:00Z"
}
+12 -12
View File
@@ -1,6 +1,6 @@
# Nextcraft — REQUIREMENTS.md
## v0.11 Requirements (ACTIVE — Placement Tracking and Audit, Feature; branch `milestone/v0.11-placement-tracking`)
## v0.11 Requirements (Complete — Placement Tracking and Audit, Feature; shipped as v0.10.6)
**Milestone type: Feature** — CIRR-style placement tracking and audit (REQ-F-020 activated per D-098). Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`. REQ-F-020 activated as REQ-11-001..005: the placement store + evidence model (REQ-11-001), the employer-side application state machine with audit-logged transitions (REQ-11-002), the placement reporting surfaces (REQ-11-003), the audit-grade export (REQ-11-004), learner-side placement visibility (REQ-11-005). Mock-first doctrine holds (no external systems, no third-party auditor integration — the export is the deliverable); tests never call a cloud.
@@ -8,31 +8,31 @@
| 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 | planned |
| 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 | planned |
| 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 | planned |
| 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 | planned |
| 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 | planned |
| 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) |
---
@@ -418,15 +418,15 @@
## Traceability Matrix
### v0.11 (active)
### v0.11 (complete)
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-11-001 | 1 | planned |
| REQ-11-002 | 2 | planned |
| REQ-11-003 | 3 | planned |
| REQ-11-004 | 4 | planned |
| REQ-11-005 | 5 | planned |
| 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)
+9 -9
View File
@@ -2,7 +2,7 @@
## Overview
**Milestone v0.11 — Placement Tracking and Audit (ACTIVE, started 2026-10-01).** 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.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`.
@@ -38,17 +38,17 @@ Payment processing and subscription management (REQ-F-018 activated per D-074, a
## Phase List
### v0.11 (active)
### v0.11 (complete — shipped as v0.10.6, 2026-10-22)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | active | — | — | Specify (D-098), clarify, research, plan, grill, MVP/UX check |
| 1 | Placement store + evidence ledger | planned | 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 | planned | 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 | planned | 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 | planned | 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 | planned | 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 | planned | 1-5 | — | Review clean; audit pass; milestone tagged v0.10.6 (final v0.10.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 |
---
+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
+256 -1
View File
@@ -38,6 +38,7 @@ import secrets
import time
import uuid
from collections import deque
from datetime import UTC, date, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request
@@ -71,13 +72,30 @@ from ..llm.base import LLMProvider
from ..marketplace.base import Posting
from ..marketplace.enrichment import maybe_enrich_on_publish
from ..marketplace.search import embed_posting
from ..marketplace.store import InvalidTransition, MarketplaceStore
from ..marketplace.store import (
InvalidTransition,
MarketplaceStore,
)
from ..marketplace.tagging import TaggingUnavailable, tag_posting
from ..placements.base import (
APPLICATION_STATUSES,
ApplicationTransitionResponse,
EmployerApplicationsResponse,
EmployerApplicationSummary,
EmployerPlacementsResponse,
HireTransitionRequest,
PlacementRead,
)
from ..placements.base import (
InvalidTransition as PlacementInvalidTransition,
)
from ..placements.store import PlacementRow, PlacementStore
from .deps import (
get_billing_store,
get_embedding_provider,
get_employer_store,
get_marketplace_store,
get_placement_store,
get_provider,
get_settings,
)
@@ -715,6 +733,243 @@ async def close_own_posting(
)
# -- hiring pipeline (v0.11 P2, Task 2-1-01 — REQ-11-002, D-105/G-311) ---------------
def _own_application_or_404(
store: MarketplaceStore, application_id: str, employer_id: str
) -> None:
"""Fetch the application → its posting → enforce the cross-entity
arm (D-105: the _own_posting_or_404 shape applied THROUGH the
application's posting — another entity's application → 403 with
NO application data; unknown → 404). G-319: learner_id never
rides this arm's error responses."""
application = store.get_application(application_id)
if application is None:
raise HTTPException(
status_code=404,
detail=f"no application with id {application_id!r}",
)
posting = store.get_posting(application.posting_id)
if posting is None: # pragma: no cover - FK discipline keeps rows paired
raise HTTPException(
status_code=404,
detail=f"no posting with id {application.posting_id!r}",
)
if posting.employer_id != employer_id:
raise HTTPException(
status_code=403,
detail="this application belongs to another employer entity",
)
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 datetime.now(UTC),
decided_at=row.decided_at or datetime.now(UTC),
)
def _employer_transition(
application_id: str,
to_status: str,
account: AccountRow,
marketplace: MarketplaceStore,
placements: PlacementStore,
employer_store: EmployerStore,
role_title: str | None = None,
start_date: date | None = None,
) -> ApplicationTransitionResponse:
"""The shared per-target transition composition (D-105): G-311
gate order — session (the route dep) → claim (_require_claimed) →
cross-entity (_own_application_or_404) → the ONE store transition
(409 on matrix-illegal, NEVER before the 403s) + the
employer_audit_events append (the D-089 one-queryable lane)."""
employer_id = _require_claimed(account)
_own_application_or_404(marketplace, application_id, employer_id)
try:
application, placement = placements.transition_application(
application_id,
to_status,
actor_type="employer",
actor_ref=f"account:{account.id}",
role_title=role_title,
start_date=start_date,
)
except PlacementInvalidTransition as exc: # MUST precede ValueError — it subclasses it
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc: # hire required-fields honesty (D-103)
raise HTTPException(status_code=422, detail=str(exc)) from exc
employer_store.append_audit_event(
"application",
f"application.{to_status}",
account_id=account.id,
employer_id=employer_id,
detail={"application_id": application_id, "to_status": to_status},
)
return ApplicationTransitionResponse(
application_id=application.id,
status=application.status, # type: ignore[arg-type]
placement=_placement_read(placement) if placement is not None else None,
)
@claims_router.get("/applications", response_model=EmployerApplicationsResponse)
async def list_own_applications(
status: str | None = None,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
] = None, # type: ignore[assignment]
store: MarketplaceStore = Depends(get_marketplace_store),
) -> EmployerApplicationsResponse:
"""The session employer's OWN hiring pipeline (D-105): every
application on the claimed entity's postings (ALL statuses), with
per-status facets. G-319 pin split: this APPLICATION-scoped read
exposes learner_id per the supersession — the own-postings LIST
stays counts-only."""
if auth is None: # pragma: no cover - the dep raises 401 first
raise HTTPException(status_code=401, detail="employer session required")
_session, account = auth
employer_id = _require_claimed(account)
postings = store.list_postings_by_employer(employer_id)
applications = store.list_applications_by_postings(
[p.id for p in postings], status=status
)
facets: dict[str, int] = {s: 0 for s in APPLICATION_STATUSES}
items = [
EmployerApplicationSummary(
id=a.id,
posting_id=a.posting_id,
learner_id=a.learner_id,
status=a.status, # type: ignore[arg-type]
created_at=a.created_at,
)
for a in applications
]
for item in items:
facets[item.status] = facets.get(item.status, 0) + 1
return EmployerApplicationsResponse(
applications=items, facets=facets, total=len(items)
)
def _own_transition_route(
to_status: str, hire: bool = False
):
"""Per-target route factory (D-105: the matrix stays OUT of request
bodies — each route's store call names its target, the publish/
close idiom). Only the hire route carries a body ({role_title,
start_date} — D-103/G-324); the others are bodyless actions."""
if hire:
async def _hire_route(
application_id: str,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
body: HireTransitionRequest,
store: MarketplaceStore = Depends(get_marketplace_store),
placements: PlacementStore = Depends(get_placement_store),
employer_store: EmployerStore = Depends(get_employer_store),
) -> ApplicationTransitionResponse:
return _employer_transition(
application_id,
to_status,
auth[1],
store,
placements,
employer_store,
role_title=body.role_title,
start_date=body.start_date,
)
return _hire_route
async def _action_route(
application_id: str,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
placements: PlacementStore = Depends(get_placement_store),
employer_store: EmployerStore = Depends(get_employer_store),
) -> ApplicationTransitionResponse:
return _employer_transition(
application_id,
to_status,
auth[1],
store,
placements,
employer_store,
)
return _action_route
claims_router.add_api_route(
"/applications/{application_id}/in-review",
_own_transition_route("in_review"),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
claims_router.add_api_route(
"/applications/{application_id}/interview",
_own_transition_route("interview"),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
claims_router.add_api_route(
"/applications/{application_id}/offer",
_own_transition_route("offer"),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
claims_router.add_api_route(
"/applications/{application_id}/reject",
_own_transition_route("rejected"),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
claims_router.add_api_route(
"/applications/{application_id}/hire",
_own_transition_route("hired", hire=True),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
# -- own placements (v0.11 P3, Task 3-1-01 — REQ-11-003, D-107/D-109) ---------------
@claims_router.get("/placements", response_model=EmployerPlacementsResponse)
async def list_own_placements(
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
placements: PlacementStore = Depends(get_placement_store),
) -> EmployerPlacementsResponse:
"""The claimed entity's OWN placements (active + revoked — the
full history, D-109 surface 2's data source): created_at then id;
honest empty state at zero placements. G-311: 401 session → 403
claim (never data before the gates)."""
_session, account = auth
employer_id = _require_claimed(account)
rows = placements.list_placements_by_employer(employer_id)
return EmployerPlacementsResponse(
placements=[_placement_read(row) for row in rows],
total=len(rows),
)
# -- self-serve billing (P5, Task 5-1-01 — REQ-10-005, R-59/G-310) ------------------
@@ -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) --------------------
@@ -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",
)
@@ -64,7 +64,9 @@ _TABLES = (
#: 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).
_AUDIT_EVENT_KINDS = ("auth", "claim", "manual")
#: 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):
+8
View File
@@ -423,6 +423,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
# 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.
@@ -353,6 +353,26 @@ class MarketplaceStore(Protocol):
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]:
@@ -916,6 +936,45 @@ class SQLiteMarketplaceStore:
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]:
@@ -182,3 +182,199 @@ class PlacementEventRead(BaseModel):
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
@@ -50,6 +50,7 @@ from .base import (
EVENT_PLACEMENT_CREATED,
EVENT_PLACEMENT_REHIRED,
EVENT_PLACEMENT_REVOKED,
EXPORT_METHODOLOGY,
GENESIS_HASH,
InvalidTransition,
validate_transition,
@@ -270,6 +271,15 @@ class PlacementStore(Protocol):
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]:
...
@@ -302,6 +312,22 @@ class PlacementStore(Protocol):
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:
...
@@ -516,6 +542,25 @@ class SQLitePlacementStore:
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)."""
@@ -672,5 +717,196 @@ class SQLitePlacementStore:
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()
+4 -2
View File
@@ -120,7 +120,9 @@ class TestSessionGate:
T+8d → the session reads as absent → 401 (no sleeps)."""
from .conftest import make_client
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
# 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"
)
@@ -140,7 +142,7 @@ class TestSessionGate:
)
assert row is None # expired — reads as absent
resp = c.get("/v1/employers/auth/me", headers=bearer("expired-token"))
# Wall clock (2026-09-30) is INSIDE the 7-day TTL → 200.
# 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.
@@ -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},
]
@@ -41,7 +41,7 @@ def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test")
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id="test",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
description="Test posting",
@@ -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>
);
}
@@ -8,6 +8,7 @@ import type {
AdminClaimsResponse,
EmployerClaim,
EmployerClaimEvidence,
PlacementOverview,
} from '@nextcraft/types';
import {
listEmployerAdminAccounts,
@@ -15,6 +16,9 @@ import {
listEmployerAdminAudit,
approveEmployerAdminClaim,
rejectEmployerAdminClaim,
getPlacementOverview,
getPlacementExport,
getPlacementExportSummary,
setAdminToken,
EngineError,
} from '../../lib/engine-client';
@@ -58,6 +62,10 @@ export function EmployerNetworkPanel() {
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);
@@ -75,14 +83,18 @@ export function EmployerNetworkPanel() {
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] = await Promise.all([
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) {
@@ -142,6 +154,39 @@ export function EmployerNetworkPanel() {
// 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">
@@ -174,6 +219,67 @@ export function EmployerNetworkPanel() {
{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">
@@ -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>
);
}
@@ -4,10 +4,16 @@ 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 } from '@nextcraft/types';
import type {
EmployerProfile,
EmployerMe,
OwnPostingsResponse,
EmployerPlacementsResponse,
} from '@nextcraft/types';
import {
getEmployerMe,
listOwnEmployerPostings,
listOwnEmployerPlacements,
getEmployer,
SessionRequiredError,
ClaimRequiredError,
@@ -33,6 +39,7 @@ 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);
@@ -45,12 +52,18 @@ export function EmployerOverviewClient() {
const meBody = await getEmployerMe(controller.signal);
setMe(meBody);
if (meBody.claimed_employer_id) {
const [own, prof] = await Promise.all([
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;
@@ -160,6 +173,10 @@ export function EmployerOverviewClient() {
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">
@@ -187,13 +204,20 @@ export function EmployerOverviewClient() {
</p>
</header>
{/* Metric cards — postings/applicants REAL (GET /v1/employers/
postings); talent-match/placement stay mock-labeled (D-092). */}
{/* 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" />
<MockMetricCard label="Placement Rate" value="—" icon={TrendingUp} tone="amber" />
</section>
{/* Middle row — per-posting applicant table + claimed-entity profile */}
@@ -286,6 +310,66 @@ export function EmployerOverviewClient() {
</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">
@@ -364,12 +448,14 @@ function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'close
}
/** D-092 honest mock label for the analytics section — the charts ride
* nowhere until a backend computes them; the copy says so. */
* 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 and placement analytics are sample data at pilot — no backend
computes them yet. Real posting and applicant counts appear above.
Talent-match analytics are sample data at pilot — no backend computes them yet.
Real posting, applicant, and placement counts appear above.
</p>
);
}
@@ -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 '';
}
}
+209
View File
@@ -47,6 +47,13 @@ import type {
AdminClaimsResponse,
ClaimDecisionResponse,
AdminAuditResponse,
EmployerApplicationsResponse,
ApplicationTransitionResponse,
HireTransitionRequest,
EmployerPlacementsResponse,
LearnerApplicationStatus,
MineApplication,
PlacementOverview,
} from '@nextcraft/types';
import { engineBaseUrl } from './engine-base-url';
@@ -1171,3 +1178,205 @@ export async function completeOwnEmployerMockCheckout(
},
);
}
// -- 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();
}
+9 -3
View File
@@ -65,8 +65,10 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
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/placement metrics stay MOCK-LABELED —
// the honest label rides the card copy (never presented as real).
// 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",
@@ -77,10 +79,14 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
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.
// 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 () => {
@@ -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)",
);
});
+168
View File
@@ -774,3 +774,171 @@ test("pricing pin: purchasable CTAs route to /employer/login; the enterprise Con
);
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.
+1
View File
@@ -4,6 +4,7 @@ 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;
}