Compare commits

...

40 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
20 changed files with 2014 additions and 34 deletions
-8
View File
@@ -1,8 +0,0 @@
{
"phase": 3,
"stage": "verify",
"milestone": "v0.11",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-10-01T09:00:00Z"
}
+10 -10
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.
@@ -20,19 +20,19 @@
| 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)
+7 -7
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 |
| 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 | 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 |
| 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
+118 -1
View File
@@ -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,7 +34,15 @@ from ..marketplace.resume import (
parse_resume,
)
from ..marketplace.search import FacetFilters, rank_postings
from ..placements.base import InvalidTransition
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
@@ -455,6 +464,114 @@ async def withdraw_application(
)
# -- 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) --------------------
@@ -15,10 +15,11 @@ 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
from fastapi import APIRouter, Depends, HTTPException, Response
from ..config import Settings
from ..marketplace.store import MarketplaceStore
@@ -228,3 +229,102 @@ async def placement_overview(
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",
)
@@ -353,6 +353,16 @@ 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]:
@@ -926,6 +936,24 @@ 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]:
@@ -245,6 +245,69 @@ class AdminTransitionRequest(BaseModel):
#: 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
@@ -272,3 +335,46 @@ class EmployerPlacementsResponse(BaseModel):
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]:
...
@@ -310,6 +320,14 @@ class PlacementStore(Protocol):
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:
...
@@ -524,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)."""
@@ -695,5 +732,181 @@ class SQLitePlacementStore:
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,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
@@ -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">
@@ -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 '';
}
}
+112
View File
@@ -51,6 +51,9 @@ import type {
ApplicationTransitionResponse,
HireTransitionRequest,
EmployerPlacementsResponse,
LearnerApplicationStatus,
MineApplication,
PlacementOverview,
} from '@nextcraft/types';
import { engineBaseUrl } from './engine-base-url';
@@ -1268,3 +1271,112 @@ export async function listOwnEmployerPlacements(
signal,
});
}
// -- learner placement status (v0.11 P5, REQ-11-005, D-104/G-322) -------------------
/** GET /v1/marketplace/applications/mine?posting_id=&learner_id= —
* G-322 (binding) discovery: the status panel finds the application by
* the (posting, learner) pair — NEVER a client-persisted
* application_id. 404 = no application yet (the surface renders the
* apply CTA — honest absence). */
export async function getMyApplication(
postingId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<MineApplication> {
return jsonFetch<MineApplication>(
`/v1/marketplace/applications/mine?posting_id=${encodeURIComponent(postingId)}&learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
}
/** GET /v1/marketplace/applications/{application_id}?learner_id= —
* D-104: the learner's-own outcome read — current status + the full
* id-ordered timestamped history + the placement record when hired
* (one read, one truth source). 403 foreign → EngineError (the
* surface renders the honest "not your application" state from
* err.status — NO new error classes, D-110). */
export async function getLearnerApplicationStatus(
applicationId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<LearnerApplicationStatus> {
return jsonFetch<LearnerApplicationStatus>(
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}?learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
}
/** POST /v1/marketplace/applications/{application_id}/withdraw — the
* learner's ONLY edit (D-099/D-104); terminal → 409 (the matrix
* surfaces as EngineError.status — the panel renders the honest
* terminal state, never a re-fetch). */
export async function withdrawApplication(
applicationId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<ApplicationConfirmation> {
return jsonFetch<ApplicationConfirmation>(
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}/withdraw`,
{
method: 'POST',
body: JSON.stringify({ learner_id: learnerId }),
signal,
},
);
}
// -- admin placements (v0.11 P3/P4, REQ-11-003/004, D-107/D-108) --------------------
/** GET /v1/placements/admin/overview — the D-107 computation over the
* G-308 admin transport: distinct learners toward the 1,000 guiding
* goal, active placements, revoked count, per-employer + per-cohort
* month breakdowns. G-328: the caller labels these as RECORDED
* placements — never a verification claim. */
export async function getPlacementOverview(
signal?: AbortSignal,
): Promise<PlacementOverview> {
return adminJsonFetch<PlacementOverview>('/v1/placements/admin/overview', {
signal,
});
}
/** GET /v1/placements/admin/export — the self-contained audit JSON
* (D-108: cohort + evidence bundles + methodology + the ledger with
* head_hash). The caller downloads the document as a JSON blob. */
export async function getPlacementExport(signal?: AbortSignal): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
/** GET /v1/placements/admin/export/summary — the human-readable
* markdown rendering FROM the same document (D-108's anti-drift
* rule); opens in-tab. */
export async function getPlacementExportSummary(
signal?: AbortSignal,
): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export/summary`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
@@ -0,0 +1,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)",
);
});
+76
View File
@@ -866,3 +866,79 @@ test("wire pin: EmployerApplicationSummary carries learner_id per the G-319 supe
"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.
+32 -1
View File
@@ -133,4 +133,35 @@ export interface PlacementOverview {
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;
}